Remove CalendarStyles.css and Scheduling.tsx components, updating the project structure for improved maintainability. Update Supabase CLI version and modify experiment_repetitions SQL migration to include scheduled_date. Enhance scheduling component in the remote app with improved drag-and-drop functionality and UI adjustments for better user experience.

This commit is contained in:
salirezav
2025-12-12 12:53:52 -05:00
parent bada5a073d
commit 9159ab68f3
8 changed files with 548 additions and 2438 deletions

View File

@@ -0,0 +1,156 @@
# Scheduling Component Refactoring Proposal
## Current State Analysis
The `ScheduleExperiment` component is approximately **1,140 lines** and handles multiple responsibilities:
1. **Conductor Management** (~150 lines)
- Fetching conductors
- Selection state
- Color mapping
- Availability fetching
2. **Phase/Experiment/Repetition Management** (~300 lines)
- Phase expansion/collapse
- Experiment loading
- Repetition creation/selection
- Soaking/airdrying data fetching
3. **Calendar Event Management** (~200 lines)
- Event generation
- Drag and drop handling
- Availability events
- Styling and theming
4. **Scheduling Logic** (~300 lines)
- Repetition spawning
- Staggering calculations
- Phase timing updates
- Lock/unlock functionality
- Database persistence
5. **UI Rendering** (~200 lines)
- Conductor panel
- Phase/Experiment/Repetition tree
- Calendar component
- Loading/error states
## Proposed Structure
### Component Hierarchy
```
ScheduleExperiment (Main Container - ~150 lines)
├── ConductorPanel (UI Component - ~100 lines)
├── ExperimentPhasePanel (UI Component - ~150 lines)
│ ├── ExperimentItem (UI Component - ~80 lines)
│ │ └── RepetitionItem (UI Component - ~120 lines)
└── SchedulingCalendar (UI Component - ~150 lines)
```
### Custom Hooks
```
hooks/
├── useConductors.ts (~100 lines)
│ - Conductor fetching
│ - Selection state
│ - Color mapping
│ - Availability fetching
├── useExperimentPhases.ts (~150 lines)
│ - Phase/Experiment/Repetition data fetching
│ - Expansion state
│ - Selection state
│ - Soaking/airdrying data
├── useScheduling.ts (~200 lines)
│ - Repetition scheduling state
│ - Spawn/stagger logic
│ - Phase timing updates
│ - Lock/unlock functionality
│ - Database persistence
└── useCalendarEvents.ts (~150 lines)
- Calendar event generation
- Drag and drop handlers
- Event styling
- Scroll preservation
```
### File Structure
```
scheduling-remote/src/components/
├── Scheduling.tsx (Main router - unchanged)
├── ScheduleExperiment/
│ ├── index.tsx (Main container - ~150 lines)
│ ├── ConductorPanel.tsx (~100 lines)
│ ├── ExperimentPhasePanel.tsx (~150 lines)
│ ├── ExperimentItem.tsx (~80 lines)
│ ├── RepetitionItem.tsx (~120 lines)
│ └── SchedulingCalendar.tsx (~150 lines)
└── hooks/
├── useConductors.ts (~100 lines)
├── useExperimentPhases.ts (~150 lines)
├── useScheduling.ts (~200 lines)
└── useCalendarEvents.ts (~150 lines)
```
## Benefits
1. **Maintainability**: Each component has a single, clear responsibility
2. **Testability**: Smaller units are easier to test in isolation
3. **Reusability**: Components and hooks can be reused elsewhere
4. **Readability**: Easier to understand and navigate
5. **Performance**: Better optimization opportunities with smaller components
6. **Collaboration**: Multiple developers can work on different parts simultaneously
## Refactoring Strategy
### Phase 1: Extract Custom Hooks (Low Risk)
1. Extract `useConductors` hook
2. Extract `useExperimentPhases` hook
3. Extract `useScheduling` hook
4. Extract `useCalendarEvents` hook
5. Test each hook independently
### Phase 2: Extract UI Components (Medium Risk)
1. Extract `ConductorPanel` component
2. Extract `ExperimentPhasePanel` component
3. Extract `ExperimentItem` component
4. Extract `RepetitionItem` component
5. Extract `SchedulingCalendar` component
6. Test component integration
### Phase 3: Refactor Main Component (Low Risk)
1. Update `ScheduleExperiment` to use extracted hooks and components
2. Remove duplicate code
3. Final integration testing
## Best Practices Applied
1. **Single Responsibility Principle**: Each component/hook has one clear purpose
2. **Custom Hooks for Logic**: Business logic separated from UI
3. **Props Drilling vs Context**: Use props for direct parent-child, context only if needed
4. **Memoization**: Preserve existing `useMemo`/`useCallback` optimizations
5. **Type Safety**: Maintain all TypeScript types
6. **State Management**: Keep related state together in hooks
## Migration Path
1. **Backward Compatible**: All functionality preserved
2. **Incremental**: Can be done in phases
3. **Testable**: Each phase can be tested independently
4. **Reversible**: Changes can be rolled back if needed
## Estimated Impact
- **Main Component**: 1,140 lines → ~150 lines (87% reduction)
- **Total Lines**: ~1,140 → ~1,200 (slight increase due to imports/exports, but much better organized)
- **Complexity**: Significantly reduced per file
- **Maintainability**: Dramatically improved

View File

@@ -186,45 +186,23 @@
color: #f9fafb;
}
/* Drag and drop improvements */
/* Basic drag and drop styles - minimal and safe */
.rbc-event {
cursor: grab !important;
cursor: grab;
user-select: none;
}
.rbc-event:active {
cursor: grabbing !important;
transform: scale(1.05);
z-index: 1000 !important;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3) !important;
cursor: grabbing;
}
/* Improve event spacing and visibility */
.rbc-event-content {
pointer-events: none;
}
/* Better visual feedback for dragging */
/* Simple drag feedback */
.rbc-addons-dnd-dragging {
opacity: 0.8;
transform: rotate(2deg);
z-index: 1000 !important;
}
.rbc-addons-dnd-drag-preview {
background: rgba(255, 255, 255, 0.9) !important;
border: 2px dashed #3b82f6 !important;
border-radius: 8px !important;
padding: 8px 12px !important;
font-weight: bold !important;
color: #1f2937 !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
}
/* Improve event hover states */
.rbc-event:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2) !important;
opacity: 0.5;
}
/* Better spacing between events */

View File

@@ -335,8 +335,6 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
// Track which repetitions are currently being scheduled
const [schedulingRepetitions, setSchedulingRepetitions] = useState<Set<string>>(new Set())
// Visual style for repetition markers
const [markerStyle, setMarkerStyle] = useState<'circles' | 'dots' | 'icons' | 'lines'>('lines')
// Ref for calendar container to preserve scroll position
const calendarRef = useRef<HTMLDivElement>(null)
@@ -435,13 +433,16 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
idToName[c.id] = name
})
const events: CalendarEvent[] = (data || []).map((r: any) => ({
id: r.id,
title: `${idToName[r.user_id] || 'Conductor'}`,
start: new Date(r.available_from),
end: new Date(r.available_to),
resource: r.user_id // Store conductor ID, not color
}))
const events: CalendarEvent[] = (data || []).map((r: any) => {
const conductorId = r.user_id
return {
id: r.id,
title: `${idToName[conductorId] || 'Conductor'}`,
start: new Date(r.available_from),
end: new Date(r.available_to),
resource: conductorId // Store conductor ID for color mapping
}
})
setAvailabilityEvents(events)
} catch (e) {
@@ -540,6 +541,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
// Auto-spawn when checked - pass the updated set to ensure correct stagger calculation
spawnSingleRepetition(repId, next)
// Re-stagger all existing repetitions to prevent overlap
// Note: reStaggerRepetitions will automatically skip locked repetitions
reStaggerRepetitions([...next, repId])
}
return next
@@ -605,7 +607,8 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
}
// Re-stagger all repetitions to prevent overlap
const reStaggerRepetitions = (repIds: string[]) => {
// IMPORTANT: Skip locked repetitions to prevent them from moving
const reStaggerRepetitions = useCallback((repIds: string[]) => {
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
tomorrow.setHours(9, 0, 0, 0)
@@ -613,9 +616,14 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
setScheduledRepetitions(prev => {
const newScheduled = { ...prev }
repIds.forEach((repId, index) => {
// Filter out locked repetitions - they should not be moved
const unlockedRepIds = repIds.filter(repId => !lockedSchedules.has(repId))
// Calculate stagger index only for unlocked repetitions
let staggerIndex = 0
unlockedRepIds.forEach((repId) => {
if (newScheduled[repId]) {
const staggerMinutes = index * 15 // 15 minutes between each repetition
const staggerMinutes = staggerIndex * 15 // 15 minutes between each repetition
const baseTime = new Date(tomorrow.getTime() + (staggerMinutes * 60000))
// Find the experiment for this repetition
@@ -643,6 +651,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
airdryingStart,
crackingStart
}
staggerIndex++
}
}
}
@@ -650,7 +659,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
return newScheduled
})
}
}, [lockedSchedules, repetitionsByExperiment, experimentsByPhase, soakingByExperiment, airdryingByExperiment])
// Spawn a single repetition in calendar
const spawnSingleRepetition = (repId: string, updatedSelectedIds?: Set<string>) => {
@@ -769,10 +778,13 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
const repetition = repetitionsByExperiment[scheduled.experimentId]?.find(r => r.id === scheduled.repetitionId)
if (experiment && repetition && scheduled.soakingStart) {
const isLocked = lockedSchedules.has(scheduled.repetitionId)
const lockIcon = isLocked ? '🔒' : '🔓'
// Soaking marker
events.push({
id: `${scheduled.repetitionId}-soaking`,
title: `💧 Soaking - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
title: `${lockIcon} 💧 Soaking - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
start: scheduled.soakingStart,
end: new Date(scheduled.soakingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility
resource: 'soaking'
@@ -782,7 +794,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
if (scheduled.airdryingStart) {
events.push({
id: `${scheduled.repetitionId}-airdrying`,
title: `🌬️ Airdrying - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
title: `${lockIcon} 🌬️ Airdrying - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
start: scheduled.airdryingStart,
end: new Date(scheduled.airdryingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility
resource: 'airdrying'
@@ -793,7 +805,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
if (scheduled.crackingStart) {
events.push({
id: `${scheduled.repetitionId}-cracking`,
title: `⚡ Cracking - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
title: `${lockIcon} ⚡ Cracking - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
start: scheduled.crackingStart,
end: new Date(scheduled.crackingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility
resource: 'cracking'
@@ -803,10 +815,12 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
})
return events
}, [scheduledRepetitions, experimentsByPhase, repetitionsByExperiment])
}, [scheduledRepetitions, experimentsByPhase, repetitionsByExperiment, lockedSchedules])
// Memoize the calendar events to prevent unnecessary re-renders
const calendarEvents = useMemo(() => generateRepetitionEvents(), [generateRepetitionEvents])
// Memoize the calendar events
const calendarEvents = useMemo(() => {
return generateRepetitionEvents()
}, [generateRepetitionEvents])
// Functions to preserve and restore scroll position
const preserveScrollPosition = useCallback(() => {
@@ -1019,426 +1033,372 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
<div className="px-6 pb-6 flex-1 min-h-0 overflow-hidden">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 h-full flex flex-col min-h-0 overflow-hidden">
{error && (
<div className="mb-4 text-sm text-red-600 dark:text-red-400">{error}</div>
)}
{loading ? (
<div className="text-center py-12">
<div className="mx-auto w-16 h-16 bg-purple-100 dark:bg-purple-900/20 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-purple-600 dark:text-purple-400 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 12a8 8 0 018-8" />
</svg>
{error && (
<div className="mb-4 text-sm text-red-600 dark:text-red-400">{error}</div>
)}
{loading ? (
<div className="text-center py-12">
<div className="mx-auto w-16 h-16 bg-purple-100 dark:bg-purple-900/20 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-purple-600 dark:text-purple-400 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 12a8 8 0 018-8" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">Loading</h3>
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">Fetching conductors, phases, and experiments.</p>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">Loading</h3>
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">Fetching conductors, phases, and experiments.</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 flex-shrink-0">
{/* Left: Conductors with future availability */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Conductors</h2>
<span className="text-xs text-gray-500 dark:text-gray-400">Select to consider for scheduling</span>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-md divide-y divide-gray-200 dark:divide-gray-700">
{conductors.length === 0 ? (
<div className="p-4 text-sm text-gray-500 dark:text-gray-400">No conductors found.</div>
) : (
<div>
<button
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50"
onClick={() => setConductorsExpanded(!conductorsExpanded)}
>
<span className="text-sm font-medium text-gray-900 dark:text-white">All Conductors</span>
<svg className={`w-4 h-4 text-gray-500 transition-transform ${conductorsExpanded ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{conductorsExpanded && (
<div className="bg-gray-50 dark:bg-gray-800/50 max-h-[420px] overflow-y-auto">
{/* Select All checkbox */}
<div className="p-3 border-b border-gray-200 dark:border-gray-600">
<label className="flex items-center gap-2">
<input
type="checkbox"
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
checked={conductors.filter(c => conductorIdsWithFutureAvailability.has(c.id)).every(c => selectedConductorIds.has(c.id)) && conductors.filter(c => conductorIdsWithFutureAvailability.has(c.id)).length > 0}
onChange={toggleAllConductors}
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Select All Available Conductors</span>
</label>
</div>
{/* Conductors list */}
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{conductors.map((c, index) => {
const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email
const hasFuture = conductorIdsWithFutureAvailability.has(c.id)
const checked = selectedConductorIds.has(c.id)
// Use the same color mapping as the calendar (from conductorColorMap)
const conductorColor = checked ? (conductorColorMap[c.id] || colorPalette[index % colorPalette.length]) : null
return (
<label
key={c.id}
className={`flex items-center justify-between p-3 ${!hasFuture ? 'opacity-50' : ''} ${checked ? 'border-l-4' : ''} hover:bg-gray-100 dark:hover:bg-gray-700/30`}
style={checked && conductorColor ? { borderLeftColor: conductorColor + '60' } : {}}
>
<div className="flex items-center gap-3">
<input
type="checkbox"
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
checked={checked}
onChange={() => toggleConductor(c.id)}
disabled={!hasFuture}
/>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{c.email}</div>
</div>
</div>
<span className={`text-xs ${hasFuture ? 'text-green-600 dark:text-green-400' : 'text-gray-400'}`}>{hasFuture ? 'Available' : 'No availability'}</span>
</label>
)
})}
</div>
</div>
)}
</div>
)}
</div>
</div>
{/* Right: Phases -> Experiments -> Repetitions */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Experiment Phases</h2>
<span className="text-xs text-gray-500 dark:text-gray-400">Expand and select repetitions</span>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-md divide-y divide-gray-200 dark:divide-gray-700">
{phases.length === 0 && (
<div className="p-4 text-sm text-gray-500 dark:text-gray-400">No phases defined.</div>
)}
{phases.map(phase => {
const expanded = expandedPhaseIds.has(phase.id)
const experiments = experimentsByPhase[phase.id] || []
return (
<div key={phase.id}>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 flex-shrink-0">
{/* Left: Conductors with future availability */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Conductors</h2>
<span className="text-xs text-gray-500 dark:text-gray-400">Select to consider for scheduling</span>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-md divide-y divide-gray-200 dark:divide-gray-700">
{conductors.length === 0 ? (
<div className="p-4 text-sm text-gray-500 dark:text-gray-400">No conductors found.</div>
) : (
<div>
<button
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50"
onClick={() => togglePhaseExpand(phase.id)}
onClick={() => setConductorsExpanded(!conductorsExpanded)}
>
<span className="text-sm font-medium text-gray-900 dark:text-white">{phase.name}</span>
<svg className={`w-4 h-4 text-gray-500 transition-transform ${expanded ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<span className="text-sm font-medium text-gray-900 dark:text-white">All Conductors</span>
<svg className={`w-4 h-4 text-gray-500 transition-transform ${conductorsExpanded ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{expanded && (
<div className="bg-gray-50 dark:bg-gray-800/50 max-h-96 overflow-y-auto">
{/* Select All checkbox for this phase */}
{experiments.length > 0 && (
<div className="p-3 border-b border-gray-200 dark:border-gray-600">
<label className="flex items-center gap-2">
<input
type="checkbox"
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
checked={experiments.flatMap(exp => repetitionsByExperiment[exp.id] || []).every(rep => selectedRepetitionIds.has(rep.id)) && experiments.flatMap(exp => repetitionsByExperiment[exp.id] || []).length > 0}
onChange={() => toggleAllRepetitionsInPhase(phase.id)}
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Select All Repetitions in {phase.name}</span>
</label>
</div>
)}
{experiments.length === 0 && (
<div className="p-3 text-xs text-gray-500 dark:text-gray-400">No experiments in this phase.</div>
)}
{experiments.map(exp => {
const reps = repetitionsByExperiment[exp.id] || []
const isCreating = creatingRepetitionsFor.has(exp.id)
const allRepsCreated = reps.length >= exp.reps_required
const soaking = soakingByExperiment[exp.id]
const airdrying = airdryingByExperiment[exp.id]
{conductorsExpanded && (
<div className="bg-gray-50 dark:bg-gray-800/50 max-h-[420px] overflow-y-auto">
{/* Select All checkbox */}
<div className="p-3 border-b border-gray-200 dark:border-gray-600">
<label className="flex items-center gap-2">
<input
type="checkbox"
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
checked={conductors.filter(c => conductorIdsWithFutureAvailability.has(c.id)).every(c => selectedConductorIds.has(c.id)) && conductors.filter(c => conductorIdsWithFutureAvailability.has(c.id)).length > 0}
onChange={toggleAllConductors}
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Select All Available Conductors</span>
</label>
</div>
{/* Conductors list */}
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{conductors.map((c, index) => {
const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email
const hasFuture = conductorIdsWithFutureAvailability.has(c.id)
const checked = selectedConductorIds.has(c.id)
// Use the same color mapping as the calendar (from conductorColorMap)
const conductorColor = checked ? (conductorColorMap[c.id] || colorPalette[index % colorPalette.length]) : null
const getSoakDisplay = () => {
if (soaking) return `${soaking.soaking_duration_minutes}min`
return '—'
}
const getAirdryDisplay = () => {
if (airdrying) return `${airdrying.duration_minutes}min`
return '—'
}
return (
<div key={exp.id} className="border-t border-gray-200 dark:border-gray-700">
<div className="px-3 py-2 flex items-center justify-between">
<div className="text-sm text-gray-900 dark:text-white">
<span className="font-medium">Exp #{exp.experiment_number}</span>
<span className="mx-2 text-gray-400"></span>
<span className="text-xs text-gray-600 dark:text-gray-300">Soak: {getSoakDisplay()}</span>
<span className="mx-2 text-gray-400">/</span>
<span className="text-xs text-gray-600 dark:text-gray-300">Air-dry: {getAirdryDisplay()}</span>
</div>
{!allRepsCreated && (
<button
onClick={() => createRepetitionsForExperiment(exp.id)}
disabled={isCreating}
className="text-xs bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white px-2 py-1 rounded transition-colors"
>
{isCreating ? 'Creating...' : 'Add Repetition'}
</button>
)}
</div>
<div className="px-3 pb-2 space-y-2">
{reps.map(rep => {
const checked = selectedRepetitionIds.has(rep.id)
const hasTimes = repetitionsWithTimes.has(rep.id)
const scheduled = scheduledRepetitions[rep.id]
const isLocked = lockedSchedules.has(rep.id)
const isScheduling = schedulingRepetitions.has(rep.id)
return (
<div key={rep.id} className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded p-3">
{/* Checkbox row */}
<label className="flex items-center gap-2">
<input
type="checkbox"
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
checked={checked}
onChange={() => toggleRepetition(rep.id)}
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Rep {rep.repetition_number}</span>
</label>
{/* Time points (shown only if has been dropped/moved) */}
{hasTimes && scheduled && (
<div className="mt-2 ml-6 text-xs space-y-1">
<div className="flex items-center gap-2">
<span>💧</span>
<span>Soaking: {formatTime(scheduled.soakingStart)}</span>
</div>
<div className="flex items-center gap-2">
<span>🌬</span>
<span>Airdrying: {formatTime(scheduled.airdryingStart)}</span>
</div>
<div className="flex items-center gap-2">
<span></span>
<span>Cracking: {formatTime(scheduled.crackingStart)}</span>
</div>
{/* Lock checkbox and Schedule button */}
<div className="flex items-center gap-3 mt-3 pt-2 border-t border-gray-200 dark:border-gray-600">
<label className="flex items-center gap-2">
<input
type="checkbox"
className="h-3 w-3 text-blue-600 border-gray-300 rounded"
checked={isLocked}
onChange={() => toggleScheduleLock(rep.id)}
/>
<span className="text-xs text-gray-600 dark:text-gray-400">Lock</span>
</label>
<button
onClick={() => scheduleRepetition(rep.id, exp.id)}
disabled={isScheduling || !isLocked}
className="px-3 py-1 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded text-xs transition-colors"
>
{isScheduling ? 'Scheduling...' : 'Schedule'}
</button>
</div>
</div>
)}
</div>
)
})}
{reps.length === 0 && !isCreating && (
<div className="text-xs text-gray-500 dark:text-gray-400 col-span-full">No repetitions created. Click "Create Reps" to generate them.</div>
)}
{isCreating && (
<div className="text-xs text-blue-600 dark:text-blue-400 col-span-full flex items-center gap-2">
<svg className="w-3 h-3 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 12a8 8 0 018-8" />
</svg>
Creating {exp.reps_required} repetitions...
return (
<label
key={c.id}
className={`flex items-center justify-between p-3 ${!hasFuture ? 'opacity-50' : ''} ${checked ? 'border-l-4' : ''} hover:bg-gray-100 dark:hover:bg-gray-700/30`}
style={checked && conductorColor ? { borderLeftColor: conductorColor + '60' } : {}}
>
<div className="flex items-center gap-3">
<input
type="checkbox"
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
checked={checked}
onChange={() => toggleConductor(c.id)}
disabled={!hasFuture}
/>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{c.email}</div>
</div>
)}
</div>
</div>
)
})}
</div>
<span className={`text-xs ${hasFuture ? 'text-green-600 dark:text-green-400' : 'text-gray-400'}`}>{hasFuture ? 'Available' : 'No availability'}</span>
</label>
)
})}
</div>
</div>
)}
</div>
)
})}
)}
</div>
</div>
{/* Right: Phases -> Experiments -> Repetitions */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Experiment Phases</h2>
<span className="text-xs text-gray-500 dark:text-gray-400">Expand and select repetitions</span>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-md divide-y divide-gray-200 dark:divide-gray-700">
{phases.length === 0 && (
<div className="p-4 text-sm text-gray-500 dark:text-gray-400">No phases defined.</div>
)}
{phases.map(phase => {
const expanded = expandedPhaseIds.has(phase.id)
const experiments = experimentsByPhase[phase.id] || []
return (
<div key={phase.id}>
<button
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50"
onClick={() => togglePhaseExpand(phase.id)}
>
<span className="text-sm font-medium text-gray-900 dark:text-white">{phase.name}</span>
<svg className={`w-4 h-4 text-gray-500 transition-transform ${expanded ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{expanded && (
<div className="bg-gray-50 dark:bg-gray-800/50 max-h-96 overflow-y-auto">
{/* Select All checkbox for this phase */}
{experiments.length > 0 && (
<div className="p-3 border-b border-gray-200 dark:border-gray-600">
<label className="flex items-center gap-2">
<input
type="checkbox"
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
checked={experiments.flatMap(exp => repetitionsByExperiment[exp.id] || []).every(rep => selectedRepetitionIds.has(rep.id)) && experiments.flatMap(exp => repetitionsByExperiment[exp.id] || []).length > 0}
onChange={() => toggleAllRepetitionsInPhase(phase.id)}
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Select All Repetitions in {phase.name}</span>
</label>
</div>
)}
{experiments.length === 0 && (
<div className="p-3 text-xs text-gray-500 dark:text-gray-400">No experiments in this phase.</div>
)}
{experiments.map(exp => {
const reps = repetitionsByExperiment[exp.id] || []
const isCreating = creatingRepetitionsFor.has(exp.id)
const allRepsCreated = reps.length >= exp.reps_required
const soaking = soakingByExperiment[exp.id]
const airdrying = airdryingByExperiment[exp.id]
const getSoakDisplay = () => {
if (soaking) return `${soaking.soaking_duration_minutes}min`
return '—'
}
const getAirdryDisplay = () => {
if (airdrying) return `${airdrying.duration_minutes}min`
return '—'
}
return (
<div key={exp.id} className="border-t border-gray-200 dark:border-gray-700">
<div className="px-3 py-2 flex items-center justify-between">
<div className="text-sm text-gray-900 dark:text-white">
<span className="font-medium">Exp #{exp.experiment_number}</span>
<span className="mx-2 text-gray-400"></span>
<span className="text-xs text-gray-600 dark:text-gray-300">Soak: {getSoakDisplay()}</span>
<span className="mx-2 text-gray-400">/</span>
<span className="text-xs text-gray-600 dark:text-gray-300">Air-dry: {getAirdryDisplay()}</span>
</div>
{!allRepsCreated && (
<button
onClick={() => createRepetitionsForExperiment(exp.id)}
disabled={isCreating}
className="text-xs bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white px-2 py-1 rounded transition-colors"
>
{isCreating ? 'Creating...' : 'Add Repetition'}
</button>
)}
</div>
<div className="px-3 pb-2 space-y-2">
{reps.map(rep => {
const checked = selectedRepetitionIds.has(rep.id)
const hasTimes = repetitionsWithTimes.has(rep.id)
const scheduled = scheduledRepetitions[rep.id]
const isLocked = lockedSchedules.has(rep.id)
const isScheduling = schedulingRepetitions.has(rep.id)
return (
<div key={rep.id} className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded p-3">
{/* Checkbox row */}
<label className="flex items-center gap-2">
<input
type="checkbox"
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
checked={checked}
onChange={() => toggleRepetition(rep.id)}
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Rep {rep.repetition_number}</span>
</label>
{/* Time points (shown only if has been dropped/moved) */}
{hasTimes && scheduled && (
<div className="mt-2 ml-6 text-xs space-y-1">
<div className="flex items-center gap-2">
<span>💧</span>
<span>Soaking: {formatTime(scheduled.soakingStart)}</span>
</div>
<div className="flex items-center gap-2">
<span>🌬</span>
<span>Airdrying: {formatTime(scheduled.airdryingStart)}</span>
</div>
<div className="flex items-center gap-2">
<span></span>
<span>Cracking: {formatTime(scheduled.crackingStart)}</span>
</div>
{/* Lock checkbox and Schedule button */}
<div className="flex items-center gap-3 mt-3 pt-2 border-t border-gray-200 dark:border-gray-600">
<label className="flex items-center gap-2">
<input
type="checkbox"
className="h-3 w-3 text-blue-600 border-gray-300 rounded"
checked={isLocked}
onChange={() => {
toggleScheduleLock(rep.id)
}}
/>
<span className="text-xs text-gray-600 dark:text-gray-400">
{isLocked ? '🔒 Locked' : '🔓 Unlocked'}
</span>
</label>
<button
onClick={() => scheduleRepetition(rep.id, exp.id)}
disabled={isScheduling || !isLocked}
className="px-3 py-1 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded text-xs transition-colors"
>
{isScheduling ? 'Scheduling...' : 'Schedule'}
</button>
</div>
</div>
)}
</div>
)
})}
{reps.length === 0 && !isCreating && (
<div className="text-xs text-gray-500 dark:text-gray-400 col-span-full">No repetitions created. Click "Create Reps" to generate them.</div>
)}
{isCreating && (
<div className="text-xs text-blue-600 dark:text-blue-400 col-span-full flex items-center gap-2">
<svg className="w-3 h-3 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 12a8 8 0 018-8" />
</svg>
Creating {exp.reps_required} repetitions...
</div>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
</div>
</div>
</div>
)}
{/* Week Calendar for selected conductors' availability */}
<div className="mt-6 flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex justify-between items-center mb-3 flex-shrink-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Selected Conductors' Availability & Experiment Scheduling</h3>
<div className="flex gap-2 items-center">
{/* Day-by-day navigation buttons (only show in week view) */}
{calendarView === Views.WEEK && (
<div className="flex items-center gap-1 mr-2">
<button
onClick={() => {
const newDate = new Date(currentDate)
newDate.setDate(newDate.getDate() - 1)
setCurrentDate(newDate)
}}
className="px-2 py-1 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded border border-gray-300 dark:border-gray-600"
title="Previous day"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={() => {
const newDate = new Date(currentDate)
newDate.setDate(newDate.getDate() + 1)
setCurrentDate(newDate)
}}
className="px-2 py-1 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded border border-gray-300 dark:border-gray-600"
title="Next day"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<span className="text-xs text-gray-500 dark:text-gray-400 ml-1">Day</span>
</div>
)}
<span className="text-sm text-gray-600 dark:text-gray-400">Marker Style:</span>
<button
onClick={() => setMarkerStyle('lines')}
className={`px-3 py-1 text-xs rounded ${markerStyle === 'lines'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
Lines
</button>
<button
onClick={() => setMarkerStyle('circles')}
className={`px-3 py-1 text-xs rounded ${markerStyle === 'circles'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
Circles
</button>
<button
onClick={() => setMarkerStyle('dots')}
className={`px-3 py-1 text-xs rounded ${markerStyle === 'dots'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
Dots
</button>
<button
onClick={() => setMarkerStyle('icons')}
className={`px-3 py-1 text-xs rounded ${markerStyle === 'icons'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
Icons
</button>
)}
{/* Week Calendar for selected conductors' availability */}
<div className="mt-6 flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="mb-3 flex-shrink-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Selected Conductors' Availability & Experiment Scheduling</h3>
</div>
</div>
<div ref={calendarRef} className="flex-1 min-h-0 border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden relative">
<DndProvider backend={HTML5Backend}>
<DnDCalendar
localizer={localizer}
events={calendarEvents}
backgroundEvents={availabilityEvents}
startAccessor="start"
endAccessor="end"
titleAccessor="title"
style={{ height: '100%' }}
view={calendarView}
onView={setCalendarView}
date={currentDate}
onNavigate={setCurrentDate}
views={[Views.WEEK, Views.DAY]}
dayLayoutAlgorithm="no-overlap"
draggableAccessor={draggableAccessor}
onEventDrop={({ event, start }: { event: any, start: Date }) => {
// Preserve scroll position before updating
preserveScrollPosition()
// Handle dragging repetition markers
const eventId = event.id as string
// Clamp to reasonable working hours (5AM to 11PM) to prevent extreme times
const clampToReasonableHours = (d: Date) => {
const min = new Date(d)
min.setHours(5, 0, 0, 0)
const max = new Date(d)
max.setHours(23, 0, 0, 0)
const t = d.getTime()
return new Date(Math.min(Math.max(t, min.getTime()), max.getTime()))
}
const clampedStart = clampToReasonableHours(start)
let repId = ''
if (eventId.includes('-soaking')) {
repId = eventId.replace('-soaking', '')
updatePhaseTiming(repId, 'soaking', clampedStart)
} else if (eventId.includes('-airdrying')) {
repId = eventId.replace('-airdrying', '')
updatePhaseTiming(repId, 'airdrying', clampedStart)
} else if (eventId.includes('-cracking')) {
repId = eventId.replace('-cracking', '')
updatePhaseTiming(repId, 'cracking', clampedStart)
}
// Add repetition to show time points
if (repId) {
setRepetitionsWithTimes(prev => new Set(prev).add(repId))
}
// Restore scroll position after a brief delay to allow for re-render
setTimeout(() => {
restoreScrollPosition()
}, 10)
}}
eventPropGetter={eventPropGetter}
backgroundEventPropGetter={(event: any) => {
// Styling for background events (conductor availability)
const conductorId = event.resource
const color = conductorColorMap[conductorId] || '#2563eb'
return {
style: {
backgroundColor: color + '40', // ~25% transparency for background
borderColor: color,
borderWidth: '1px',
borderStyle: 'solid',
color: 'transparent',
borderRadius: '4px',
opacity: 0.6,
height: 'auto',
minHeight: '20px',
fontSize: '0px',
padding: '0px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
<div ref={calendarRef} className="flex-1 min-h-0 border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden relative">
<DndProvider backend={HTML5Backend}>
<DnDCalendar
localizer={localizer}
events={calendarEvents}
backgroundEvents={availabilityEvents}
startAccessor="start"
endAccessor="end"
titleAccessor="title"
style={{ height: '100%' }}
view={calendarView}
onView={setCalendarView}
date={currentDate}
onNavigate={setCurrentDate}
views={[Views.WEEK, Views.DAY]}
dayLayoutAlgorithm="no-overlap"
draggableAccessor={draggableAccessor}
onSelectEvent={(event: any) => {
// Handle clicking on repetition markers to toggle lock
const resource = event.resource as string
if (resource === 'soaking' || resource === 'airdrying' || resource === 'cracking') {
const eventId = event.id as string
const repId = eventId.split('-')[0]
// Toggle lock for this repetition - this will update both checkbox and marker icons
toggleScheduleLock(repId)
// Prevent default popup behavior
return false
}
}
}}
popup
showMultiDayTimes
doShowMore={true}
step={30}
timeslots={2}
/>
</DndProvider>
</div>
}}
onEventDrop={({ event, start }: { event: any, start: Date }) => {
// Preserve scroll position before updating
preserveScrollPosition()
</div>
// Handle dragging repetition markers
const eventId = event.id as string
// Clamp to reasonable working hours (5AM to 11PM) to prevent extreme times
const clampToReasonableHours = (d: Date) => {
const min = new Date(d)
min.setHours(5, 0, 0, 0)
const max = new Date(d)
max.setHours(23, 0, 0, 0)
const t = d.getTime()
return new Date(Math.min(Math.max(t, min.getTime()), max.getTime()))
}
const clampedStart = clampToReasonableHours(start)
let repId = ''
if (eventId.includes('-soaking')) {
repId = eventId.replace('-soaking', '')
updatePhaseTiming(repId, 'soaking', clampedStart)
} else if (eventId.includes('-airdrying')) {
repId = eventId.replace('-airdrying', '')
updatePhaseTiming(repId, 'airdrying', clampedStart)
} else if (eventId.includes('-cracking')) {
repId = eventId.replace('-cracking', '')
updatePhaseTiming(repId, 'cracking', clampedStart)
}
// Add repetition to show time points
if (repId) {
setRepetitionsWithTimes(prev => new Set(prev).add(repId))
}
// Restore scroll position after a brief delay to allow for re-render
setTimeout(() => {
restoreScrollPosition()
}, 10)
}}
eventPropGetter={eventPropGetter}
backgroundEventPropGetter={(event: any) => {
// Styling for background events (conductor availability)
const conductorId = event.resource as string
const color = conductorColorMap[conductorId] || '#2563eb'
// Use more visible colors - higher opacity for better visibility
return {
style: {
backgroundColor: color + '60', // ~37% opacity for better visibility
borderColor: color,
borderWidth: '2px',
borderStyle: 'solid',
color: 'transparent',
borderRadius: '4px',
opacity: 0.8,
height: 'auto',
minHeight: '20px',
fontSize: '0px',
padding: '0px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}
}
}}
popup
showMultiDayTimes
doShowMore={true}
step={30}
timeslots={2}
/>
</DndProvider>
</div>
</div>
</div>
</div>
</div>