From 9159ab68f30792080a9bc444866b2f8de9bc6bf1 Mon Sep 17 00:00:00 2001 From: salirezav Date: Fri, 12 Dec 2025 12:53:52 -0500 Subject: [PATCH] Remove CalendarStyles.css and Scheduling.tsx components, updating the project structure for improved maintainability. Update Supabase CLI version and modify experiment_repetitions SQL migration to include scheduled_date. Enhance scheduling component in the remote app with improved drag-and-drop functionality and UI adjustments for better user experience. --- .../src/components/CalendarStyles.css | 250 --- .../src/components/Scheduling.tsx | 1738 ----------------- .../supabase/.temp/cli-latest | 2 +- .../00006_experiment_repetitions.sql | 1 + ...dd_repetition_id_to_cracker_parameters.sql | 3 + scheduling-remote/REFACTORING_PROPOSAL.md | 156 ++ .../src/components/CalendarStyles.css | 32 +- .../src/components/Scheduling.tsx | 804 ++++---- 8 files changed, 548 insertions(+), 2438 deletions(-) delete mode 100644 management-dashboard-web-app/src/components/CalendarStyles.css delete mode 100644 management-dashboard-web-app/src/components/Scheduling.tsx create mode 100644 scheduling-remote/REFACTORING_PROPOSAL.md 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" - > -
-
-
-
- - - -
-
-
- - 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 - // 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 ( -
-
- -

- 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 */} -
- -
- -
- ) -} 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.
- ) : ( -
- - {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 ( -
+ ) : ( +
+ {/* Left: Conductors with future availability */} +
+
+

Conductors

+ Select to consider for scheduling +
+
+ {conductors.length === 0 ? ( +
No conductors found.
+ ) : ( +
- {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] + {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 - 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... + return ( +
- ) - })} +
+ {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 ( +
+ + {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: - - - - + )} + {/* 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} + /> + +
+ +