diff --git a/management-dashboard-web-app/src/components/CalendarStyles.css b/management-dashboard-web-app/src/components/CalendarStyles.css
deleted file mode 100644
index 329394d..0000000
--- a/management-dashboard-web-app/src/components/CalendarStyles.css
+++ /dev/null
@@ -1,250 +0,0 @@
-/* Custom styles for React Big Calendar to match dashboard theme */
-
-.rbc-calendar {
- background: white;
- border-radius: 8px;
- box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
-}
-
-/* Dark mode support */
-.dark .rbc-calendar {
- background: #1f2937;
- color: #f9fafb;
-}
-
-/* Header styling */
-.rbc-header {
- background: #f8fafc;
- border-bottom: 1px solid #e2e8f0;
- color: #374151;
- font-weight: 600;
- padding: 12px 8px;
-}
-
-.dark .rbc-header {
- background: #374151;
- border-bottom: 1px solid #4b5563;
- color: #f9fafb;
-}
-
-/* Today styling */
-.rbc-today {
- background: #eff6ff;
-}
-
-.dark .rbc-today {
- background: #1e3a8a;
-}
-
-/* Date cells */
-.rbc-date-cell {
- color: #374151;
- font-weight: 500;
-}
-
-.dark .rbc-date-cell {
- color: #f9fafb;
-}
-
-/* Event styling */
-.rbc-event {
- border-radius: 4px;
- border: none;
- font-size: 12px;
- font-weight: 500;
- padding: 2px 4px;
-}
-
-.rbc-event-content {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-/* Month view specific */
-.rbc-month-view {
- border: 1px solid #e2e8f0;
- border-radius: 8px;
-}
-
-.dark .rbc-month-view {
- border: 1px solid #4b5563;
-}
-
-.rbc-month-row {
- border-bottom: 1px solid #e2e8f0;
-}
-
-.dark .rbc-month-row {
- border-bottom: 1px solid #4b5563;
-}
-
-.rbc-date-cell {
- padding: 8px;
-}
-
-/* Week and day view specific */
-.rbc-time-view {
- border: 1px solid #e2e8f0;
- border-radius: 8px;
-}
-
-.dark .rbc-time-view {
- border: 1px solid #4b5563;
-}
-
-.rbc-time-header {
- background: #f8fafc;
- border-bottom: 1px solid #e2e8f0;
-}
-
-.dark .rbc-time-header {
- background: #374151;
- border-bottom: 1px solid #4b5563;
-}
-
-.rbc-time-content {
- background: white;
-}
-
-.dark .rbc-time-content {
- background: #1f2937;
-}
-
-/* Time slots */
-.rbc-time-slot {
- border-top: 1px solid #f1f5f9;
- color: #64748b;
-}
-
-.dark .rbc-time-slot {
- border-top: 1px solid #374151;
- color: #9ca3af;
-}
-
-/* Toolbar */
-.rbc-toolbar {
- background: #f8fafc;
- border-bottom: 1px solid #e2e8f0;
- padding: 12px 16px;
- margin-bottom: 0;
-}
-
-.dark .rbc-toolbar {
- background: #374151;
- border-bottom: 1px solid #4b5563;
-}
-
-.rbc-toolbar button {
- background: white;
- border: 1px solid #d1d5db;
- border-radius: 6px;
- color: #374151;
- font-weight: 500;
- padding: 6px 12px;
- transition: all 0.2s;
-}
-
-.rbc-toolbar button:hover {
- background: #f3f4f6;
- border-color: #9ca3af;
-}
-
-.rbc-toolbar button:active,
-.rbc-toolbar button.rbc-active {
- background: #3b82f6;
- border-color: #3b82f6;
- color: white;
-}
-
-.dark .rbc-toolbar button {
- background: #1f2937;
- border: 1px solid #4b5563;
- color: #f9fafb;
-}
-
-.dark .rbc-toolbar button:hover {
- background: #374151;
- border-color: #6b7280;
-}
-
-.dark .rbc-toolbar button:active,
-.dark .rbc-toolbar button.rbc-active {
- background: #3b82f6;
- border-color: #3b82f6;
- color: white;
-}
-
-/* Labels */
-.rbc-toolbar-label {
- color: #111827;
- font-size: 18px;
- font-weight: 600;
-}
-
-.dark .rbc-toolbar-label {
- color: #f9fafb;
-}
-
-/* Drag and drop improvements */
-.rbc-event {
- cursor: grab !important;
- 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;
-}
-
-/* Improve event spacing and visibility */
-.rbc-event-content {
- pointer-events: none;
-}
-
-/* Better visual feedback for dragging */
-.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;
-}
-
-/* Better spacing between events */
-.rbc-time-slot {
- min-height: 24px;
-}
-
-/* Responsive adjustments */
-@media (max-width: 768px) {
- .rbc-toolbar {
- flex-direction: column;
- gap: 8px;
- }
-
- .rbc-toolbar button {
- font-size: 14px;
- padding: 8px 12px;
- }
-
- .rbc-toolbar-label {
- font-size: 16px;
- }
-}
\ No newline at end of file
diff --git a/management-dashboard-web-app/src/components/Scheduling.tsx b/management-dashboard-web-app/src/components/Scheduling.tsx
deleted file mode 100644
index aa23073..0000000
--- a/management-dashboard-web-app/src/components/Scheduling.tsx
+++ /dev/null
@@ -1,1738 +0,0 @@
-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 '../lib/supabase'
-import { availabilityManagement, userManagement, experimentPhaseManagement, experimentManagement, repetitionManagement, phaseManagement, supabase } from '../lib/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"
- >
-
-
-
-
- View Complete Schedule
-
-
-
- View the complete schedule of all upcoming experiment runs and their current status.
-
-
-
-
-
-
- {/* 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"
- >
-
-
-
-
- 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"
- >
-
-
-
-
- Schedule Experiment
-
-
-
- Schedule specific experiment runs and assign team members to upcoming sessions.
-
-
-
-
Experiment planning
-
-
-
-
-
-
- )
-}
-
-// Placeholder components for the three scheduling features
-function ViewSchedule({ user, onBack }: { user: User; onBack: () => void }) {
- // User context available for future features
- return (
-
-
-
-
-
-
- Back to Scheduling
-
-
- 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 (
-
-
-
-
-
-
- Back to Scheduling
-
-
- 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
- // Query availability table directly for efficiency
- const conductorIds = conductorsOnly.map(c => c.id)
- // Fallback: since availabilityManagement is scoped to current user, we query via supabase client here would require direct import.
- // To avoid that in UI layer, approximate by marking all conductors as selectable initially.
- // In a later iteration, backend endpoint can provide available conductors. For now, consider all conductors as available some time in future.
- 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 (
-
-
-
-
-
-
- Back to Scheduling
-
-
- 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.
- ) : (
-
-
setConductorsExpanded(!conductorsExpanded)}
- >
- All Conductors
-
-
-
-
- {conductorsExpanded && (
-
- {/* Select All checkbox */}
-
-
- conductorIdsWithFutureAvailability.has(c.id)).every(c => selectedConductorIds.has(c.id)) && conductors.filter(c => conductorIdsWithFutureAvailability.has(c.id)).length > 0}
- onChange={toggleAllConductors}
- />
- Select All Available Conductors
-
-
- {/* 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 (
-
-
-
toggleConductor(c.id)}
- disabled={!hasFuture}
- />
-
-
- {hasFuture ? 'Available' : 'No availability'}
-
- )
- })}
-
-
- )}
-
- )}
-
-
-
- {/* 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 (
-
-
togglePhaseExpand(phase.id)}
- >
- {phase.name}
-
-
-
-
- {expanded && (
-
- {/* Select All checkbox for this phase */}
- {experiments.length > 0 && (
-
-
- repetitionsByExperiment[exp.id] || []).every(rep => selectedRepetitionIds.has(rep.id)) && experiments.flatMap(exp => repetitionsByExperiment[exp.id] || []).length > 0}
- onChange={() => toggleAllRepetitionsInPhase(phase.id)}
- />
- Select All Repetitions in {phase.name}
-
-
- )}
- {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 && (
-
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'}
-
- )}
-
-
- {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 */}
-
- toggleRepetition(rep.id)}
- />
- Rep {rep.repetition_number}
-
-
- {/* 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 */}
-
-
- toggleScheduleLock(rep.id)}
- />
- Lock
-
- 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'}
-
-
-
- )}
-
- )
- })}
- {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 && (
-
-
{
- 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"
- >
-
-
-
-
-
{
- 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"
- >
-
-
-
-
-
Day
-
- )}
-
Marker Style:
-
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
-
-
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
-
-
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
-
-
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
-
-
-
-
-
- {
- // 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 */}
-
-
-
- {/* Time Slot Form Modal */}
- {showTimeSlotForm && selectedDate && (
-
-
-
e.stopPropagation()}>
- {/* Close Button */}
-
-
-
-
-
-
-
- Add Availability for {moment(selectedDate).format('MMMM D, YYYY')}
-
-
-
-
- {/* 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})
-
- ))}
-
-
- )}
-
-
-
- Cancel
-
-
- Add Time Slot
-
-
-
-
- )}
-
- {/* Calendar */}
-
-
-
-
-
- )
-}
diff --git a/management-dashboard-web-app/supabase/.temp/cli-latest b/management-dashboard-web-app/supabase/.temp/cli-latest
index a900aa6..1b2faa2 100755
--- a/management-dashboard-web-app/supabase/.temp/cli-latest
+++ b/management-dashboard-web-app/supabase/.temp/cli-latest
@@ -1 +1 @@
-v2.65.2
\ No newline at end of file
+v2.65.5
\ No newline at end of file
diff --git a/management-dashboard-web-app/supabase/migrations/00006_experiment_repetitions.sql b/management-dashboard-web-app/supabase/migrations/00006_experiment_repetitions.sql
index 92623a3..660c8c6 100644
--- a/management-dashboard-web-app/supabase/migrations/00006_experiment_repetitions.sql
+++ b/management-dashboard-web-app/supabase/migrations/00006_experiment_repetitions.sql
@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS public.experiment_repetitions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
experiment_id UUID NOT NULL REFERENCES public.experiments(id) ON DELETE CASCADE,
repetition_number INTEGER NOT NULL CHECK (repetition_number > 0),
+ scheduled_date TIMESTAMP WITH TIME ZONE,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed', 'cancelled')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
diff --git a/management-dashboard-web-app/supabase/migrations/00013_add_repetition_id_to_cracker_parameters.sql b/management-dashboard-web-app/supabase/migrations/00013_add_repetition_id_to_cracker_parameters.sql
index c16cc20..bd30fc3 100644
--- a/management-dashboard-web-app/supabase/migrations/00013_add_repetition_id_to_cracker_parameters.sql
+++ b/management-dashboard-web-app/supabase/migrations/00013_add_repetition_id_to_cracker_parameters.sql
@@ -36,3 +36,6 @@ ADD CONSTRAINT unique_meyer_cracker_parameters_per_repetition
+
+
+
diff --git a/scheduling-remote/REFACTORING_PROPOSAL.md b/scheduling-remote/REFACTORING_PROPOSAL.md
new file mode 100644
index 0000000..105ba5b
--- /dev/null
+++ b/scheduling-remote/REFACTORING_PROPOSAL.md
@@ -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
+
+
+
+
diff --git a/scheduling-remote/src/components/CalendarStyles.css b/scheduling-remote/src/components/CalendarStyles.css
index 329394d..38e0f6c 100644
--- a/scheduling-remote/src/components/CalendarStyles.css
+++ b/scheduling-remote/src/components/CalendarStyles.css
@@ -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 */
diff --git a/scheduling-remote/src/components/Scheduling.tsx b/scheduling-remote/src/components/Scheduling.tsx
index 0743873..d50e09e 100644
--- a/scheduling-remote/src/components/Scheduling.tsx
+++ b/scheduling-remote/src/components/Scheduling.tsx
@@ -335,8 +335,6 @@ function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }
// 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)
@@ -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) => {
@@ -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 }
- {error && (
-
{error}
- )}
- {loading ? (
-
-
-
-
-
+ {error && (
+
{error}
+ )}
+ {loading ? (
+
+
+
Loadingβ¦
+
Fetching conductors, phases, and experiments.
-
Loadingβ¦
-
Fetching conductors, phases, and experiments.
-
- ) : (
-
- {/* Left: Conductors with future availability */}
-
-
-
Conductors
- Select to consider for scheduling
-
-
- {conductors.length === 0 ? (
-
No conductors found.
- ) : (
-
-
setConductorsExpanded(!conductorsExpanded)}
- >
- All Conductors
-
-
-
-
- {conductorsExpanded && (
-
- {/* Select All checkbox */}
-
-
- conductorIdsWithFutureAvailability.has(c.id)).every(c => selectedConductorIds.has(c.id)) && conductors.filter(c => conductorIdsWithFutureAvailability.has(c.id)).length > 0}
- onChange={toggleAllConductors}
- />
- Select All Available Conductors
-
-
- {/* 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 (
-
-
-
toggleConductor(c.id)}
- disabled={!hasFuture}
- />
-
-
- {hasFuture ? 'Available' : 'No availability'}
-
- )
- })}
-
-
- )}
-
- )}
-
-
-
- {/* 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 (
-
+ ) : (
+
+ {/* Left: Conductors with future availability */}
+
+
+
Conductors
+ Select to consider for scheduling
+
+
+ {conductors.length === 0 ? (
+
No conductors found.
+ ) : (
+
togglePhaseExpand(phase.id)}
+ onClick={() => setConductorsExpanded(!conductorsExpanded)}
>
- {phase.name}
-
+ All Conductors
+
- {expanded && (
-
- {/* Select All checkbox for this phase */}
- {experiments.length > 0 && (
-
-
- repetitionsByExperiment[exp.id] || []).every(rep => selectedRepetitionIds.has(rep.id)) && experiments.flatMap(exp => repetitionsByExperiment[exp.id] || []).length > 0}
- onChange={() => toggleAllRepetitionsInPhase(phase.id)}
- />
- Select All Repetitions in {phase.name}
-
-
- )}
- {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]
+ {conductorsExpanded && (
+
+ {/* Select All checkbox */}
+
+
+ conductorIdsWithFutureAvailability.has(c.id)).every(c => selectedConductorIds.has(c.id)) && conductors.filter(c => conductorIdsWithFutureAvailability.has(c.id)).length > 0}
+ onChange={toggleAllConductors}
+ />
+ Select All Available Conductors
+
+
+ {/* 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
- 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 && (
-
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'}
-
- )}
-
-
- {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 */}
-
- toggleRepetition(rep.id)}
- />
- Rep {rep.repetition_number}
-
-
- {/* 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 */}
-
-
- toggleScheduleLock(rep.id)}
- />
- Lock
-
- 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'}
-
-
-
- )}
-
- )
- })}
- {reps.length === 0 && !isCreating && (
-
No repetitions created. Click "Create Reps" to generate them.
- )}
- {isCreating && (
-
-
-
-
- Creating {exp.reps_required} repetitions...
+ return (
+
+
+
toggleConductor(c.id)}
+ disabled={!hasFuture}
+ />
+
- )}
-
-
- )
- })}
+
+
{hasFuture ? 'Available' : 'No availability'}
+
+ )
+ })}
+
)}
- )
- })}
+ )}
+
+
+
+ {/* 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 (
+
+
togglePhaseExpand(phase.id)}
+ >
+ {phase.name}
+
+
+
+
+ {expanded && (
+
+ {/* Select All checkbox for this phase */}
+ {experiments.length > 0 && (
+
+
+ repetitionsByExperiment[exp.id] || []).every(rep => selectedRepetitionIds.has(rep.id)) && experiments.flatMap(exp => repetitionsByExperiment[exp.id] || []).length > 0}
+ onChange={() => toggleAllRepetitionsInPhase(phase.id)}
+ />
+ Select All Repetitions in {phase.name}
+
+
+ )}
+ {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 && (
+
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'}
+
+ )}
+
+
+ {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 */}
+
+ toggleRepetition(rep.id)}
+ />
+ Rep {rep.repetition_number}
+
+
+ {/* 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 */}
+
+
+ {
+ toggleScheduleLock(rep.id)
+ }}
+ />
+
+ {isLocked ? 'π Locked' : 'π Unlocked'}
+
+
+ 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'}
+
+
+
+ )}
+
+ )
+ })}
+ {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 && (
-
-
{
- 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"
- >
-
-
-
-
-
{
- 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"
- >
-
-
-
-
-
Day
-
- )}
-
Marker Style:
-
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
-
-
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
-
-
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
-
-
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
-
+ )}
+ {/* Week Calendar for selected conductors' availability */}
+
+
+
Selected Conductors' Availability & Experiment Scheduling
-
-
-
- {
- // 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'
+
+
+ {
+ // 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}
- />
-
-
+ }}
+ 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 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}
+ />
+
+
+
+