import { useEffect, useState, useMemo, useCallback, useRef } from 'react' // @ts-ignore - react-big-calendar types not available import { Calendar, momentLocalizer, Views } from 'react-big-calendar' // @ts-ignore - react-big-calendar dragAndDrop types not available import withDragAndDrop from 'react-big-calendar/lib/addons/dragAndDrop' import { DndProvider } from 'react-dnd' import { HTML5Backend } from 'react-dnd-html5-backend' import moment from 'moment' import type { User, ExperimentPhase, Experiment, ExperimentRepetition, Soaking, Airdrying } from '../services/supabase' import { availabilityManagement, userManagement, experimentPhaseManagement, experimentManagement, repetitionManagement, phaseManagement, supabase } from '../services/supabase' import 'react-big-calendar/lib/css/react-big-calendar.css' import './CalendarStyles.css' // Type definitions for calendar events interface CalendarEvent { id: number | string title: string start: Date end: Date resource?: string } interface SchedulingProps { user: User currentRoute: string } type SchedulingView = 'main' | 'view-schedule' | 'indicate-availability' | 'schedule-experiment' export function Scheduling({ user, currentRoute }: SchedulingProps) { // Extract current view from route const getCurrentView = (): SchedulingView => { if (currentRoute === '/scheduling') { return 'main' } const match = currentRoute.match(/^\/scheduling\/(.+)$/) if (match) { const subRoute = match[1] switch (subRoute) { case 'view-schedule': return 'view-schedule' case 'indicate-availability': return 'indicate-availability' case 'schedule-experiment': return 'schedule-experiment' default: return 'main' } } return 'main' } const currentView = getCurrentView() const handleCardClick = (view: SchedulingView) => { const newPath = view === 'main' ? '/scheduling' : `/scheduling/${view}` window.history.pushState({}, '', newPath) // Trigger a popstate event to update the route window.dispatchEvent(new PopStateEvent('popstate')) } const handleBackToMain = () => { window.history.pushState({}, '', '/scheduling') // Trigger a popstate event to update the route window.dispatchEvent(new PopStateEvent('popstate')) } // Render different views based on currentView if (currentView === 'view-schedule') { return } if (currentView === 'indicate-availability') { return } if (currentView === 'schedule-experiment') { return } // Main view with cards return (

Scheduling

This is the scheduling module of the dashboard. Here you can indicate your availability for upcoming experiment runs.

{/* Scheduling Cards Grid */}
{/* View Complete Schedule Card */}
handleCardClick('view-schedule')} className="bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-gray-200 dark:border-gray-700 hover:border-blue-300" >
Available

View Complete Schedule

View the complete schedule of all upcoming experiment runs and their current status.

All experiments
View Schedule
{/* Indicate Availability Card */}
handleCardClick('indicate-availability')} className="bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-gray-200 dark:border-gray-700 hover:border-blue-300" >
Active

Indicate Your Availability

Set your availability preferences and time slots for upcoming experiment runs.

Personal settings
Set Availability
{/* Schedule Experiment Card */}
handleCardClick('schedule-experiment')} className="bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-gray-200 dark:border-gray-700 hover:border-blue-300" >
Planning

Schedule Experiment

Schedule specific experiment runs and assign team members to upcoming sessions.

Experiment planning
Schedule Now
) } // Placeholder components for the three scheduling features function ViewSchedule({ user, onBack }: { user: User; onBack: () => void }) { // User context available for future features return (

Complete Schedule

View all scheduled experiment runs and their current status.

Complete Schedule View

This view will show a comprehensive calendar and list of all scheduled experiment runs, including dates, times, assigned team members, and current status.

) } function IndicateAvailability({ user, onBack }: { user: User; onBack: () => void }) { return (

Indicate Availability

Set your availability preferences and time slots for upcoming experiment runs.

) } function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }) { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [conductors, setConductors] = useState([]) const [conductorIdsWithFutureAvailability, setConductorIdsWithFutureAvailability] = useState>(new Set()) const [selectedConductorIds, setSelectedConductorIds] = useState>(new Set()) const [phases, setPhases] = useState([]) const [expandedPhaseIds, setExpandedPhaseIds] = useState>(new Set()) const [conductorsExpanded, setConductorsExpanded] = useState(true) const [experimentsByPhase, setExperimentsByPhase] = useState>({}) const [repetitionsByExperiment, setRepetitionsByExperiment] = useState>({}) const [selectedRepetitionIds, setSelectedRepetitionIds] = useState>(new Set()) const [creatingRepetitionsFor, setCreatingRepetitionsFor] = useState>(new Set()) const [soakingByExperiment, setSoakingByExperiment] = useState>({}) const [airdryingByExperiment, setAirdryingByExperiment] = useState>({}) // Calendar state for selected conductors' availability const localizer = momentLocalizer(moment) const DnDCalendar = withDragAndDrop(Calendar) const [calendarView, setCalendarView] = useState(Views.WEEK) const [currentDate, setCurrentDate] = useState(new Date()) const [availabilityEvents, setAvailabilityEvents] = useState([]) // Color map per conductor for calendar events const [conductorColorMap, setConductorColorMap] = useState>({}) const colorPalette = ['#2563eb', '#059669', '#d97706', '#7c3aed', '#dc2626', '#0ea5e9', '#16a34a', '#f59e0b', '#9333ea', '#ef4444'] // Repetition scheduling state const [scheduledRepetitions, setScheduledRepetitions] = useState>({}) // Track repetitions that have been dropped/moved and should show time points const [repetitionsWithTimes, setRepetitionsWithTimes] = useState>(new Set()) // Track which repetitions are locked (prevent dragging) const [lockedSchedules, setLockedSchedules] = useState>(new Set()) // Track which repetitions are currently being scheduled const [schedulingRepetitions, setSchedulingRepetitions] = useState>(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(null) const scrollPositionRef = useRef<{ scrollTop: number; scrollLeft: number } | null>(null) useEffect(() => { const load = async () => { try { setLoading(true) setError(null) const [allUsers, allPhases] = await Promise.all([ userManagement.getAllUsers(), experimentPhaseManagement.getAllExperimentPhases() ]) // Filter conductors const conductorsOnly = allUsers.filter(u => u.roles.includes('conductor')) setConductors(conductorsOnly) // For each conductor, check if they have any availability in the future const conductorIds = conductorsOnly.map(c => c.id) setConductorIdsWithFutureAvailability(new Set(conductorIds)) setPhases(allPhases) } catch (e: any) { setError(e?.message || 'Failed to load scheduling data') } finally { setLoading(false) } } load() }, []) const toggleConductor = (id: string) => { setSelectedConductorIds(prev => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } const toggleAllConductors = () => { const availableConductorIds = conductors .filter(c => conductorIdsWithFutureAvailability.has(c.id)) .map(c => c.id) setSelectedConductorIds(prev => { // If all available conductors are selected, deselect all const allSelected = availableConductorIds.every(id => prev.has(id)) if (allSelected) { return new Set() } else { // Select all available conductors return new Set(availableConductorIds) } }) } // Fetch availability for selected conductors and build calendar events useEffect(() => { const loadSelectedAvailability = async () => { try { const selectedIds = Array.from(selectedConductorIds) if (selectedIds.length === 0) { setAvailabilityEvents([]) return } // 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 = {} conductors.forEach((conductor, index) => { if (selectedIds.includes(conductor.id)) { newColorMap[conductor.id] = colorPalette[index % colorPalette.length] } }) setConductorColorMap(newColorMap) // Fetch availability for selected conductors in a single query const { data, error } = await supabase .from('conductor_availability') .select('*') .in('user_id', selectedIds) .eq('status', 'active') .gt('available_to', new Date().toISOString()) .order('available_from', { ascending: true }) if (error) throw error // Map user_id -> display name const idToName: Record = {} conductors.forEach(c => { const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email 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 })) setAvailabilityEvents(events) } catch (e) { // Fail silently for calendar, do not break main UI setAvailabilityEvents([]) } } loadSelectedAvailability() // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedConductorIds, conductors]) const togglePhaseExpand = async (phaseId: string) => { setExpandedPhaseIds(prev => { const next = new Set(prev) if (next.has(phaseId)) next.delete(phaseId) else next.add(phaseId) return next }) // Lazy-load experiments for this phase if not loaded if (!experimentsByPhase[phaseId]) { try { const exps = await experimentManagement.getExperimentsByPhaseId(phaseId) setExperimentsByPhase(prev => ({ ...prev, [phaseId]: exps })) // For each experiment, load repetitions and phase data const repsEntries = await Promise.all( exps.map(async (exp) => { const [reps, soaking, airdrying] = await Promise.all([ repetitionManagement.getExperimentRepetitions(exp.id), phaseManagement.getSoakingByExperimentId(exp.id), phaseManagement.getAirdryingByExperimentId(exp.id) ]) return [exp.id, reps, soaking, airdrying] as const }) ) // Update repetitions setRepetitionsByExperiment(prev => ({ ...prev, ...Object.fromEntries(repsEntries.map(([id, reps]) => [id, reps])) })) // Update soaking data setSoakingByExperiment(prev => ({ ...prev, ...Object.fromEntries(repsEntries.map(([id, , soaking]) => [id, soaking])) })) // Update airdrying data setAirdryingByExperiment(prev => ({ ...prev, ...Object.fromEntries(repsEntries.map(([id, , , airdrying]) => [id, airdrying])) })) } catch (e: any) { setError(e?.message || 'Failed to load experiments or repetitions') } } } const toggleRepetition = (repId: string) => { setSelectedRepetitionIds(prev => { const next = new Set(prev) if (next.has(repId)) { next.delete(repId) // Remove from scheduled repetitions when unchecked setScheduledRepetitions(prevScheduled => { const newScheduled = { ...prevScheduled } delete newScheduled[repId] return newScheduled }) // Clear all related state when unchecked setRepetitionsWithTimes(prev => { const next = new Set(prev) next.delete(repId) return next }) setLockedSchedules(prev => { const next = new Set(prev) next.delete(repId) return next }) setSchedulingRepetitions(prev => { const next = new Set(prev) next.delete(repId) return next }) // Re-stagger remaining repetitions const remainingIds = Array.from(next).filter(id => id !== repId) if (remainingIds.length > 0) { reStaggerRepetitions(remainingIds) } } else { next.add(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]) } return next }) } const toggleAllRepetitionsInPhase = (phaseId: string) => { const experiments = experimentsByPhase[phaseId] || [] const allRepetitions = experiments.flatMap(exp => repetitionsByExperiment[exp.id] || []) setSelectedRepetitionIds(prev => { // Check if all repetitions in this phase are selected const allSelected = allRepetitions.every(rep => prev.has(rep.id)) if (allSelected) { // Deselect all repetitions in this phase const next = new Set(prev) allRepetitions.forEach(rep => { next.delete(rep.id) // Remove from scheduled repetitions setScheduledRepetitions(prevScheduled => { const newScheduled = { ...prevScheduled } delete newScheduled[rep.id] return newScheduled }) }) return next } else { // Select all repetitions in this phase const next = new Set(prev) allRepetitions.forEach(rep => { next.add(rep.id) // Auto-spawn when checked - pass the updated set to ensure correct stagger calculation spawnSingleRepetition(rep.id, next) }) return next } }) } const createRepetitionsForExperiment = async (experimentId: string) => { try { setCreatingRepetitionsFor(prev => new Set(prev).add(experimentId)) setError(null) // Create all repetitions for this experiment const newRepetitions = await repetitionManagement.createAllRepetitions(experimentId) // Update the repetitions state setRepetitionsByExperiment(prev => ({ ...prev, [experimentId]: newRepetitions })) } catch (e: any) { setError(e?.message || 'Failed to create repetitions') } finally { setCreatingRepetitionsFor(prev => { const next = new Set(prev) next.delete(experimentId) return next }) } } // Re-stagger all repetitions to prevent overlap const reStaggerRepetitions = (repIds: string[]) => { const tomorrow = new Date() tomorrow.setDate(tomorrow.getDate() + 1) tomorrow.setHours(9, 0, 0, 0) setScheduledRepetitions(prev => { const newScheduled = { ...prev } repIds.forEach((repId, index) => { if (newScheduled[repId]) { const staggerMinutes = index * 15 // 15 minutes between each repetition const baseTime = new Date(tomorrow.getTime() + (staggerMinutes * 60000)) // Find the experiment for this repetition let experimentId = '' for (const [expId, reps] of Object.entries(repetitionsByExperiment)) { if (reps.find(r => r.id === repId)) { experimentId = expId break } } if (experimentId) { const experiment = Object.values(experimentsByPhase).flat().find(e => e.id === experimentId) const soaking = soakingByExperiment[experimentId] const airdrying = airdryingByExperiment[experimentId] if (experiment && soaking && airdrying) { const soakingStart = new Date(baseTime) const airdryingStart = new Date(soakingStart.getTime() + (soaking.soaking_duration_minutes * 60000)) const crackingStart = new Date(airdryingStart.getTime() + (airdrying.duration_minutes * 60000)) newScheduled[repId] = { ...newScheduled[repId], soakingStart, airdryingStart, crackingStart } } } } }) return newScheduled }) } // Spawn a single repetition in calendar const spawnSingleRepetition = (repId: string, updatedSelectedIds?: Set) => { const tomorrow = new Date() tomorrow.setDate(tomorrow.getDate() + 1) tomorrow.setHours(9, 0, 0, 0) // Default to 9 AM tomorrow // Find the experiment for this repetition let experimentId = '' for (const [expId, reps] of Object.entries(repetitionsByExperiment)) { if (reps.find(r => r.id === repId)) { experimentId = expId break } } if (experimentId) { const experiment = Object.values(experimentsByPhase).flat().find(e => e.id === experimentId) const soaking = soakingByExperiment[experimentId] const airdrying = airdryingByExperiment[experimentId] if (experiment && soaking && airdrying) { // Stagger the positioning to avoid overlap when multiple repetitions are selected // 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) // 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)) const crackingStart = new Date(airdryingStart.getTime() + (airdrying.duration_minutes * 60000)) setScheduledRepetitions(prev => ({ ...prev, [repId]: { repetitionId: repId, experimentId, soakingStart, airdryingStart, crackingStart } })) } } } // Update phase timing when a marker is moved const updatePhaseTiming = (repId: string, phase: 'soaking' | 'airdrying' | 'cracking', newTime: Date) => { setScheduledRepetitions(prev => { const current = prev[repId] if (!current) return prev const experimentId = current.experimentId const soaking = soakingByExperiment[experimentId] const airdrying = airdryingByExperiment[experimentId] if (!soaking || !airdrying) return prev let newScheduled = { ...prev } 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())) } if (phase === 'soaking') { const soakingStart = clampToReasonableHours(newTime) const airdryingStart = clampToReasonableHours(new Date(soakingStart.getTime() + (soaking.soaking_duration_minutes * 60000))) const crackingStart = clampToReasonableHours(new Date(airdryingStart.getTime() + (airdrying.duration_minutes * 60000))) newScheduled[repId] = { ...current, soakingStart, airdryingStart, crackingStart } } else if (phase === 'airdrying') { const airdryingStart = clampToReasonableHours(newTime) const soakingStart = clampToReasonableHours(new Date(airdryingStart.getTime() - (soaking.soaking_duration_minutes * 60000))) const crackingStart = clampToReasonableHours(new Date(airdryingStart.getTime() + (airdrying.duration_minutes * 60000))) newScheduled[repId] = { ...current, soakingStart, airdryingStart, crackingStart } } else if (phase === 'cracking') { const crackingStart = clampToReasonableHours(newTime) const airdryingStart = clampToReasonableHours(new Date(crackingStart.getTime() - (airdrying.duration_minutes * 60000))) const soakingStart = clampToReasonableHours(new Date(airdryingStart.getTime() - (soaking.soaking_duration_minutes * 60000))) newScheduled[repId] = { ...current, soakingStart, airdryingStart, crackingStart } } return newScheduled }) } // Generate calendar events for scheduled repetitions (memoized) const generateRepetitionEvents = useCallback((): CalendarEvent[] => { const events: CalendarEvent[] = [] Object.values(scheduledRepetitions).forEach(scheduled => { const experiment = Object.values(experimentsByPhase).flat().find(e => e.id === scheduled.experimentId) const repetition = repetitionsByExperiment[scheduled.experimentId]?.find(r => r.id === scheduled.repetitionId) if (experiment && repetition && scheduled.soakingStart) { // Soaking marker events.push({ id: `${scheduled.repetitionId}-soaking`, title: `💧 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' }) // Airdrying marker if (scheduled.airdryingStart) { events.push({ id: `${scheduled.repetitionId}-airdrying`, title: `🌬️ 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' }) } // Cracking marker if (scheduled.crackingStart) { events.push({ id: `${scheduled.repetitionId}-cracking`, title: `⚡ 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' }) } } }) return events }, [scheduledRepetitions, experimentsByPhase, repetitionsByExperiment]) // Memoize the calendar events to prevent unnecessary re-renders const calendarEvents = useMemo(() => generateRepetitionEvents(), [generateRepetitionEvents]) // Functions to preserve and restore scroll position const preserveScrollPosition = useCallback(() => { if (calendarRef.current) { const scrollContainer = calendarRef.current.querySelector('.rbc-time-content') as HTMLElement if (scrollContainer) { scrollPositionRef.current = { scrollTop: scrollContainer.scrollTop, scrollLeft: scrollContainer.scrollLeft } } } }, []) const restoreScrollPosition = useCallback(() => { if (calendarRef.current && scrollPositionRef.current) { const scrollContainer = calendarRef.current.querySelector('.rbc-time-content') as HTMLElement if (scrollContainer) { scrollContainer.scrollTop = scrollPositionRef.current.scrollTop scrollContainer.scrollLeft = scrollPositionRef.current.scrollLeft } } }, []) // Helper functions for scheduling const formatTime = (date: Date | null) => { if (!date) return 'Not set' return moment(date).format('MMM D, h:mm A') } const toggleScheduleLock = (repId: string) => { setLockedSchedules(prev => { const next = new Set(prev) if (next.has(repId)) { next.delete(repId) } else { next.add(repId) } return next }) } const draggableAccessor = useCallback((event: any) => { // Only make repetition markers draggable, not availability events const resource = event.resource as string if (resource === 'soaking' || resource === 'airdrying' || resource === 'cracking') { // Check if the repetition is locked const eventId = event.id as string const repId = eventId.split('-')[0] const isLocked = lockedSchedules.has(repId) return !isLocked } return false }, [lockedSchedules]) const eventPropGetter = useCallback((event: any) => { const resource = event.resource as string // Styling for repetition markers (foreground events) if (resource === 'soaking' || resource === 'airdrying' || resource === 'cracking') { const eventId = event.id as string const repId = eventId.split('-')[0] const isLocked = lockedSchedules.has(repId) const colors = { soaking: '#3b82f6', // blue airdrying: '#10b981', // green cracking: '#f59e0b' // orange } const color = colors[resource as keyof typeof colors] || '#6b7280' return { style: { backgroundColor: isLocked ? '#9ca3af' : color, // gray if locked borderColor: isLocked ? color : color, // border takes original color when locked color: 'white', borderRadius: '8px', border: '2px solid', height: '40px', minHeight: '40px', fontSize: '12px', padding: '8px 12px', display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-start', fontWeight: 'bold', zIndex: 10, position: 'relative', lineHeight: '1.4', textShadow: '1px 1px 2px rgba(0,0,0,0.7)', gap: '8px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isLocked ? 'not-allowed' : 'grab', boxShadow: isLocked ? '0 1px 2px rgba(0,0,0,0.1)' : '0 2px 4px rgba(0,0,0,0.2)', transition: 'all 0.2s ease', opacity: isLocked ? 0.7 : 1 } } } // Default styling for other events return {} }, [lockedSchedules]) const scheduleRepetition = async (repId: string, experimentId: string) => { setSchedulingRepetitions(prev => new Set(prev).add(repId)) try { const scheduled = scheduledRepetitions[repId] if (!scheduled) throw new Error('No scheduled times found') const { soakingStart, airdryingStart, crackingStart } = scheduled if (!soakingStart || !airdryingStart || !crackingStart) { throw new Error('All time points must be set') } const soaking = soakingByExperiment[experimentId] const airdrying = airdryingByExperiment[experimentId] if (!soaking || !airdrying) throw new Error('Phase data not found') // Update repetition scheduled_date (earliest time point) await repetitionManagement.updateRepetition(repId, { scheduled_date: soakingStart.toISOString() }) // Create/update soaking record with repetition_id await phaseManagement.createSoaking({ repetition_id: repId, scheduled_start_time: soakingStart.toISOString(), soaking_duration_minutes: soaking.soaking_duration_minutes }) // Create/update airdrying record with repetition_id await phaseManagement.createAirdrying({ repetition_id: repId, scheduled_start_time: airdryingStart.toISOString(), duration_minutes: airdrying.duration_minutes }) // Create/update cracking record with repetition_id // Note: cracking requires machine_type_id - need to get from experiment phase const experiment = Object.values(experimentsByPhase).flat().find(e => e.id === experimentId) const phase = phases.find(p => p.id === experiment?.phase_id) if (phase?.cracking_machine_type_id) { await phaseManagement.createCracking({ repetition_id: repId, machine_type_id: phase.cracking_machine_type_id, scheduled_start_time: crackingStart.toISOString() }) } // Update local state to reflect scheduling setRepetitionsByExperiment(prev => ({ ...prev, [experimentId]: prev[experimentId]?.map(r => r.id === repId ? { ...r, scheduled_date: soakingStart.toISOString() } : r ) || [] })) } catch (error: any) { setError(error?.message || 'Failed to schedule repetition') } finally { setSchedulingRepetitions(prev => { const next = new Set(prev) next.delete(repId) return next }) } } // Restore scroll position after scheduledRepetitions changes useEffect(() => { if (scrollPositionRef.current) { // Use a longer delay to ensure the calendar has fully re-rendered const timeoutId = setTimeout(() => { restoreScrollPosition() }, 50) return () => clearTimeout(timeoutId) } }, [scheduledRepetitions, restoreScrollPosition]) return (

Schedule Experiment

Schedule specific experiment runs and assign team members to upcoming sessions.

{error && (
{error}
)} {loading ? (

Loading…

Fetching conductors, phases, and experiments.

) : (
{/* Left: Conductors with future availability */}

Conductors

Select to consider for scheduling
{conductors.length === 0 ? (
No conductors found.
) : (
{conductorsExpanded && (
{/* Select All checkbox */}
{/* Conductors list */}
{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 ( ) })}
)}
)}
{/* Right: Phases -> Experiments -> Repetitions */}

Experiment Phases

Expand and select repetitions
{phases.length === 0 && (
No phases defined.
)} {phases.map(phase => { const expanded = expandedPhaseIds.has(phase.id) const experiments = experimentsByPhase[phase.id] || [] return (
{expanded && (
{/* Select All checkbox for this phase */} {experiments.length > 0 && (
)} {experiments.length === 0 && (
No experiments in this phase.
)} {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 (
Exp #{exp.experiment_number} Soak: {getSoakDisplay()} / Air-dry: {getAirdryDisplay()}
{!allRepsCreated && ( )}
{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 (
{/* Checkbox row */} {/* Time points (shown only if has been dropped/moved) */} {hasTimes && scheduled && (
💧 Soaking: {formatTime(scheduled.soakingStart)}
🌬️ Airdrying: {formatTime(scheduled.airdryingStart)}
Cracking: {formatTime(scheduled.crackingStart)}
{/* Lock checkbox and Schedule button */}
)}
) })} {reps.length === 0 && !isCreating && (
No repetitions created. Click "Create Reps" to generate them.
)} {isCreating && (
Creating {exp.reps_required} repetitions...
)}
) })}
)}
) })}
)} {/* Week Calendar for selected conductors' availability */}

Selected Conductors' Availability & Experiment Scheduling

{/* Day-by-day navigation buttons (only show in week view) */} {calendarView === Views.WEEK && (
Day
)} Marker Style:
{ // 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' } } }} popup showMultiDayTimes doShowMore={true} step={30} timeslots={2} />
) } // Availability Calendar Component function AvailabilityCalendar({ user }: { user: User }) { // User context available for future features like saving preferences const localizer = momentLocalizer(moment) const [events, setEvents] = useState([]) const [selectedDate, setSelectedDate] = useState(null) const [showTimeSlotForm, setShowTimeSlotForm] = useState(false) const [newTimeSlot, setNewTimeSlot] = useState({ startTime: '09:00', endTime: '17:00' }) const [currentView, setCurrentView] = useState(Views.MONTH) const [currentDate, setCurrentDate] = useState(new Date()) // Load availability from DB on mount useEffect(() => { const loadAvailability = async () => { try { const records = await availabilityManagement.getMyAvailability() const mapped: CalendarEvent[] = records.map(r => ({ id: r.id, title: 'Available', start: new Date(r.available_from), end: new Date(r.available_to), resource: 'available' })) setEvents(mapped) } catch (e) { console.error('Failed to load availability', e) } } loadAvailability() }, []) const eventStyleGetter = (event: CalendarEvent) => { return { style: { backgroundColor: '#10b981', // green-500 for available borderColor: '#10b981', color: 'white', borderRadius: '4px', border: 'none', display: 'block' } } } const handleSelectSlot = ({ start, end }: { start: Date; end: Date }) => { // Set the selected date and show the time slot form setSelectedDate(start) setShowTimeSlotForm(true) // Pre-fill the form with the selected time range const startTime = moment(start).format('HH:mm') const endTime = moment(end).format('HH:mm') setNewTimeSlot({ startTime, endTime }) } const handleSelectEvent = async (event: CalendarEvent) => { if (!window.confirm('Do you want to remove this availability?')) { return } try { if (typeof event.id === 'string') { await availabilityManagement.deleteAvailability(event.id) } setEvents(events.filter(e => e.id !== event.id)) } catch (e: any) { alert(e?.message || 'Failed to delete availability.') console.error('Failed to delete availability', e) } } const handleAddTimeSlot = async () => { if (!selectedDate) return const [startHour, startMinute] = newTimeSlot.startTime.split(':').map(Number) const [endHour, endMinute] = newTimeSlot.endTime.split(':').map(Number) const startDateTime = new Date(selectedDate) startDateTime.setHours(startHour, startMinute, 0, 0) const endDateTime = new Date(selectedDate) endDateTime.setHours(endHour, endMinute, 0, 0) // Check for overlapping events const hasOverlap = events.some(event => { const eventStart = new Date(event.start) const eventEnd = new Date(event.end) return ( eventStart.toDateString() === selectedDate.toDateString() && ((startDateTime >= eventStart && startDateTime < eventEnd) || (endDateTime > eventStart && endDateTime <= eventEnd) || (startDateTime <= eventStart && endDateTime >= eventEnd)) ) }) if (hasOverlap) { alert('This time slot overlaps with an existing availability. Please choose a different time.') return } try { // Persist to DB first to get real ID and server validation const created = await availabilityManagement.createAvailability({ available_from: startDateTime.toISOString(), available_to: endDateTime.toISOString() }) const newEvent: CalendarEvent = { id: created.id, title: 'Available', start: new Date(created.available_from), end: new Date(created.available_to), resource: 'available' } setEvents([...events, newEvent]) setShowTimeSlotForm(false) setSelectedDate(null) } catch (e: any) { alert(e?.message || 'Failed to save availability. Please try again.') console.error('Failed to create availability', e) } } const handleCancelTimeSlot = () => { setShowTimeSlotForm(false) setSelectedDate(null) } const getEventsForDate = (date: Date) => { return events.filter(event => { const eventDate = new Date(event.start) return eventDate.toDateString() === date.toDateString() }) } return (
{/* Calendar Header */}

Availability Calendar

Click and drag to add availability slots, or click on existing events to remove them. You can add multiple time slots per day.

{/* Legend */}
Available
{/* Time Slot Form Modal */} {showTimeSlotForm && selectedDate && (
e.stopPropagation()}> {/* Close Button */}

Add Availability for {moment(selectedDate).format('MMMM D, YYYY')}

setNewTimeSlot({ ...newTimeSlot, startTime: e.target.value })} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white" />
setNewTimeSlot({ ...newTimeSlot, endTime: e.target.value })} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white" />
{/* Show existing time slots for this date */} {getEventsForDate(selectedDate).length > 0 && (

Existing time slots:

{getEventsForDate(selectedDate).map(event => (
{moment(event.start).format('HH:mm')} - {moment(event.end).format('HH:mm')} ({event.title})
))}
)}
)} {/* Calendar */}
) }