Enhance scheduling component and improve data handling

- Added conductorsExpanded state to manage the visibility of the conductors list in the scheduling component.
- Updated color assignment logic for conductors to ensure consistent coloring based on their position in the array.
- Modified spawnSingleRepetition function to accept an updated set of selected IDs for accurate stagger calculations.
- Refactored soaking and airdrying data retrieval to map results from a unified experiment_phase_executions table, improving data consistency.
- Enhanced UI for conductor selection, including a collapsible list and improved availability indicators.
This commit is contained in:
salirezav
2025-12-05 11:10:21 -05:00
parent 933d4417a5
commit bada5a073d
6 changed files with 586 additions and 118 deletions

View File

@@ -300,6 +300,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
const [phases, setPhases] = useState<ExperimentPhase[]>([])
const [expandedPhaseIds, setExpandedPhaseIds] = useState<Set<string>>(new Set())
const [conductorsExpanded, setConductorsExpanded] = useState<boolean>(true)
const [experimentsByPhase, setExperimentsByPhase] = useState<Record<string, Experiment[]>>({})
const [repetitionsByExperiment, setRepetitionsByExperiment] = useState<Record<string, ExperimentRepetition[]>>({})
const [selectedRepetitionIds, setSelectedRepetitionIds] = useState<Set<string>>(new Set())
@@ -406,10 +407,13 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
return
}
// Assign consistent colors per selected conductor
// Assign consistent colors per conductor based on their position in the full conductors array
// This ensures the same conductor always gets the same color, matching the checkbox list
const newColorMap: Record<string, string> = {}
selectedIds.forEach((conductorId, index) => {
newColorMap[conductorId] = colorPalette[index % colorPalette.length]
conductors.forEach((conductor, index) => {
if (selectedIds.includes(conductor.id)) {
newColorMap[conductor.id] = colorPalette[index % colorPalette.length]
}
})
setConductorColorMap(newColorMap)
@@ -533,8 +537,8 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
}
} else {
next.add(repId)
// Auto-spawn when checked
spawnSingleRepetition(repId)
// Auto-spawn when checked - pass the updated set to ensure correct stagger calculation
spawnSingleRepetition(repId, next)
// Re-stagger all existing repetitions to prevent overlap
reStaggerRepetitions([...next, repId])
}
@@ -568,8 +572,8 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
const next = new Set(prev)
allRepetitions.forEach(rep => {
next.add(rep.id)
// Auto-spawn when checked
spawnSingleRepetition(rep.id)
// Auto-spawn when checked - pass the updated set to ensure correct stagger calculation
spawnSingleRepetition(rep.id, next)
})
return next
}
@@ -649,7 +653,7 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
}
// Spawn a single repetition in calendar
const spawnSingleRepetition = (repId: string) => {
const spawnSingleRepetition = (repId: string, updatedSelectedIds?: Set<string>) => {
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
tomorrow.setHours(9, 0, 0, 0) // Default to 9 AM tomorrow
@@ -670,9 +674,11 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
if (experiment && soaking && airdrying) {
// Stagger the positioning to avoid overlap when multiple repetitions are selected
const selectedReps = Array.from(selectedRepetitionIds)
// Use the updated set if provided, otherwise use current state (may be stale)
const selectedReps = updatedSelectedIds ? Array.from(updatedSelectedIds) : Array.from(selectedRepetitionIds)
const repIndex = selectedReps.indexOf(repId)
const staggerMinutes = repIndex * 15 // 15 minutes between each repetition's time points
// If repId not found in selectedReps, use the count of scheduled repetitions as fallback
const staggerMinutes = repIndex >= 0 ? repIndex * 15 : Object.keys(scheduledRepetitions).length * 15
const soakingStart = new Date(tomorrow.getTime() + (staggerMinutes * 60000))
const airdryingStart = new Date(soakingStart.getTime() + (soaking.soaking_duration_minutes * 60000))
@@ -1034,51 +1040,71 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
<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>
{/* Select All checkbox for conductors */}
<div className="mb-3">
<label className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700/50 rounded border border-gray-200 dark:border-gray-600">
<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>
<div className="max-h-[420px] overflow-y-auto divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
{conductors.length === 0 && (
<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>
)}
{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)
const conductorColor = checked ? 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' : ''}`}
style={checked && conductorColor ? { borderLeftColor: conductorColor + '60' } : {}}
) : (
<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)}
>
<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>
<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>
<span className={`text-xs ${hasFuture ? 'text-green-600 dark:text-green-400' : 'text-gray-400'}`}>{hasFuture ? 'Available in future' : 'No future availability'}</span>
</label>
)
})}
)}
</div>
)}
</div>
</div>
@@ -1251,7 +1277,39 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
<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">
<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')}

View File

@@ -370,18 +370,36 @@ export const phaseManagement = {
return null
}
// Get soaking for the first repetition
// Get soaking from unified experiment_phase_executions table
const { data, error } = await supabase
.from('soaking')
.from('experiment_phase_executions')
.select('*')
.eq('repetition_id', repetitions[0].id)
.eq('phase_type', 'soaking')
.single()
if (error) {
if (error.code === 'PGRST116') return null
if (error.code === 'PGRST116') return null // Not found
throw error
}
return data
// Map unified table data to Soaking interface format
if (data) {
return {
id: data.id,
repetition_id: data.repetition_id,
scheduled_start_time: data.scheduled_start_time,
actual_start_time: data.actual_start_time || null,
soaking_duration_minutes: data.soaking_duration_minutes || 0,
scheduled_end_time: data.scheduled_end_time || '',
actual_end_time: data.actual_end_time || null,
created_at: data.created_at,
updated_at: data.updated_at,
created_by: data.created_by
} as Soaking
}
return null
},
async getAirdryingByExperimentId(experimentId: string): Promise<Airdrying | null> {
@@ -397,18 +415,36 @@ export const phaseManagement = {
return null
}
// Get airdrying for the first repetition
// Get airdrying from unified experiment_phase_executions table
const { data, error } = await supabase
.from('airdrying')
.from('experiment_phase_executions')
.select('*')
.eq('repetition_id', repetitions[0].id)
.eq('phase_type', 'airdrying')
.single()
if (error) {
if (error.code === 'PGRST116') return null
if (error.code === 'PGRST116') return null // Not found
throw error
}
return data
// Map unified table data to Airdrying interface format
if (data) {
return {
id: data.id,
repetition_id: data.repetition_id,
scheduled_start_time: data.scheduled_start_time,
actual_start_time: data.actual_start_time || null,
duration_minutes: data.duration_minutes || 0,
scheduled_end_time: data.scheduled_end_time || '',
actual_end_time: data.actual_end_time || null,
created_at: data.created_at,
updated_at: data.updated_at,
created_by: data.created_by
} as Airdrying
}
return null
},
}