diff --git a/camera-management-api/usda_vision_system/camera/recorder.py b/camera-management-api/usda_vision_system/camera/recorder.py
index 7e7a233..d7a0388 100644
--- a/camera-management-api/usda_vision_system/camera/recorder.py
+++ b/camera-management-api/usda_vision_system/camera/recorder.py
@@ -459,6 +459,13 @@ class CameraRecorder:
with self._lock:
if self.recording:
self.logger.warning("Already recording!")
+ # #region agent log
+ import json
+ try:
+ with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
+ f.write(json.dumps({"location": "recorder.py:460", "message": "start_recording: already recording check", "data": {"camera": self.camera_config.name, "recording_flag": self.recording, "thread_alive": self._recording_thread.is_alive() if self._recording_thread else False}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "G"}) + "\n")
+ except: pass
+ # #endregion
return False
# Check if streamer is active - if so, we can share frames without opening a new camera connection
@@ -503,6 +510,13 @@ class CameraRecorder:
# Update state
self.recording = True
+ # #region agent log
+ import json
+ try:
+ with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
+ f.write(json.dumps({"location": "recorder.py:505", "message": "start_recording: setting recording=True", "data": {"camera": self.camera_config.name, "filename": filename}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "A"}) + "\n")
+ except: pass
+ # #endregion
recording_id = self.state_manager.start_recording(self.camera_config.name, output_path)
# Publish event
@@ -567,6 +581,13 @@ class CameraRecorder:
with self._lock:
# Update state
self.recording = False
+ # #region agent log
+ import json
+ try:
+ with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
+ f.write(json.dumps({"location": "recorder.py:569", "message": "stop_recording: setting recording=False", "data": {"camera": self.camera_config.name}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "F"}) + "\n")
+ except: pass
+ # #endregion
# Calculate duration and file size
duration = 0
@@ -620,9 +641,23 @@ class CameraRecorder:
while initial_frame is None and time.time() - timeout_start < 5.0:
if self._stop_recording_event.is_set():
self.logger.error("Stop event set before getting initial frame")
+ # #region agent log
+ import json
+ try:
+ with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
+ f.write(json.dumps({"location": "recorder.py:623", "message": "Early return: stop event set", "data": {"camera": self.camera_config.name, "recording_flag": self.recording}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "A"}) + "\n")
+ except: pass
+ # #endregion
return
if not self.streamer.streaming:
self.logger.error("Streamer stopped before getting initial frame")
+ # #region agent log
+ import json
+ try:
+ with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
+ f.write(json.dumps({"location": "recorder.py:625", "message": "Early return: streamer stopped", "data": {"camera": self.camera_config.name, "recording_flag": self.recording}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "B"}) + "\n")
+ except: pass
+ # #endregion
return
try:
initial_frame = self.streamer._recording_frame_queue.get(timeout=0.5)
@@ -632,11 +667,25 @@ class CameraRecorder:
if initial_frame is None:
self.logger.error("Failed to get initial frame from streamer for video writer initialization")
+ # #region agent log
+ import json
+ try:
+ with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
+ f.write(json.dumps({"location": "recorder.py:634", "message": "Early return: failed to get initial frame", "data": {"camera": self.camera_config.name, "recording_flag": self.recording}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "C"}) + "\n")
+ except: pass
+ # #endregion
return
# Initialize video writer (with initial frame dimensions if using streamer frames)
if not self._initialize_video_writer(use_streamer_frames=use_streamer_frames, initial_frame=initial_frame):
self.logger.error("Failed to initialize video writer")
+ # #region agent log
+ import json
+ try:
+ with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
+ f.write(json.dumps({"location": "recorder.py:640", "message": "Early return: failed to initialize video writer", "data": {"camera": self.camera_config.name, "recording_flag": self.recording}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "D"}) + "\n")
+ except: pass
+ # #endregion
return
self.logger.info(f"Recording loop started (using {'streamer frames' if use_streamer_frames else 'direct capture'})")
@@ -734,8 +783,26 @@ class CameraRecorder:
finally:
self.logger.info("Cleaning up recording resources...")
self._cleanup_recording()
- # Note: Don't set self.recording = False here - let stop_recording() handle it
- # to avoid race conditions where stop_recording thinks recording already stopped
+ # #region agent log
+ import json
+ try:
+ with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
+ f.write(json.dumps({"location": "recorder.py:734", "message": "Finally block: before setting recording flag", "data": {"camera": self.camera_config.name, "recording_flag_before": self.recording, "stop_event_set": self._stop_recording_event.is_set()}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "post-fix", "hypothesisId": "E"}) + "\n")
+ except: pass
+ # #endregion
+ # Reset recording flag if thread exits early (due to error) or if stop_recording wasn't called
+ # This prevents the flag from staying True when the thread exits early
+ # Using lock to ensure thread safety - if stop_recording() already set it to False, this is harmless
+ with self._lock:
+ if self.recording:
+ self.logger.warning("Recording thread exited but flag was still True - resetting to False")
+ self.recording = False
+ # #region agent log
+ try:
+ with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
+ f.write(json.dumps({"location": "recorder.py:745", "message": "Finally block: after setting recording flag", "data": {"camera": self.camera_config.name, "recording_flag_after": self.recording}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "post-fix", "hypothesisId": "E"}) + "\n")
+ except: pass
+ # #endregion
def _initialize_video_writer(self, use_streamer_frames: bool = False, initial_frame: Optional[np.ndarray] = None) -> bool:
"""Initialize OpenCV video writer
@@ -768,15 +835,27 @@ class CameraRecorder:
self.logger.info(f"Got frame dimensions from streamer's camera: {frame_size}")
except Exception as e:
self.logger.error(f"Failed to get frame dimensions from streamer camera: {e}")
- # Use camera config defaults as last resort
- camera_config = self.camera_config
- frame_size = (camera_config.resolution_width or 1280, camera_config.resolution_height or 1024)
- self.logger.warning(f"Using default frame size from config: {frame_size}")
+ # #region agent log
+ import json
+ try:
+ with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
+ f.write(json.dumps({"location": "recorder.py:837", "message": "Fallback path triggered: failed to get dimensions from streamer camera", "data": {"camera": self.camera_config.name, "error": str(e)}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "A"}) + "\n")
+ except: pass
+ # #endregion
+ # Use hardcoded defaults as last resort (CameraConfig doesn't have resolution_width/height fields)
+ frame_size = (1280, 1024)
+ self.logger.warning(f"Using hardcoded default frame size: {frame_size}")
else:
- # Use camera config defaults as last resort
- camera_config = self.camera_config
- frame_size = (camera_config.resolution_width or 1280, camera_config.resolution_height or 1024)
- self.logger.warning(f"Using default frame size from config: {frame_size}")
+ # #region agent log
+ import json
+ try:
+ with open('/home/alireza/Desktop/USDA-VISION/.cursor/debug.log', 'a') as f:
+ f.write(json.dumps({"location": "recorder.py:842", "message": "Fallback path triggered: no camera handle available", "data": {"camera": self.camera_config.name, "use_streamer_frames": use_streamer_frames, "has_streamer": self.streamer is not None, "has_streamer_camera": self.streamer.hCamera is not None if self.streamer else False}, "timestamp": time.time(), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "B"}) + "\n")
+ except: pass
+ # #endregion
+ # Use hardcoded defaults as last resort (CameraConfig doesn't have resolution_width/height fields)
+ frame_size = (1280, 1024)
+ self.logger.warning(f"Using hardcoded default frame size: {frame_size}")
# Set up video writer with configured codec
fourcc = cv2.VideoWriter_fourcc(*self.camera_config.video_codec)
diff --git a/docs/UGA_SSO_Integration_Guide.md b/docs/UGA_SSO_Integration_Guide.md
new file mode 100644
index 0000000..2650084
--- /dev/null
+++ b/docs/UGA_SSO_Integration_Guide.md
@@ -0,0 +1,199 @@
+# UGA SSO Integration Guide
+
+## Overview
+
+The University of Georgia (UGA) uses **Central Authentication Service (CAS)** for Single Sign-On (SSO) authentication. This allows users to log in to multiple applications using their MyID and password, with optional two-factor authentication via ArchPass (powered by Duo).
+
+## Current Authentication System
+
+The dashboard currently uses **Supabase** for authentication with email/password login. The authentication is implemented in:
+- `management-dashboard-web-app/src/components/Login.tsx` - Login form component
+- `management-dashboard-web-app/src/lib/supabase.ts` - Supabase client and user management
+- `management-dashboard-web-app/src/App.tsx` - Authentication state management
+
+## UGA SSO Integration Process
+
+### Step 1: Submit SSO Integration Request
+
+**Action Required:** Submit the official UGA SSO Integration Request form to UGA's Enterprise Information Technology Services (EITS).
+
+**Resources:**
+- **SSO Integration Request Form:** https://eits.uga.edu/access_and_security/uga_sso/moving_to_uga_sso/sso_integration_request/
+- **UGA SSO Information Page:** https://eits.uga.edu/access_and_security/uga_sso/
+- **What is CAS?** https://eits.uga.edu/access_and_security/new_cas/what_is_cas/
+
+The integration request form collects essential information about your application and initiates the security review process.
+
+### Step 2: Security Review
+
+After submitting the request, UGA's **Office of Information Security** will contact you to:
+- Arrange security testing of your application
+- Ensure your application meets UGA's security standards
+- Provide guidance on security requirements
+
+**Note:** This is a mandatory step before your application can be integrated with UGA SSO.
+
+### Step 3: Technical Support
+
+For technical assistance during the integration process:
+- **Request UGA SSO Support:** Submit a request through EITS
+- **Community CAS Support:** Available on the UGA SSO page
+- **Developer Tools:** Additional resources available on the UGA SSO page
+
+## Technical Considerations
+
+### CAS Authentication Flow
+
+CAS (Central Authentication Service) typically follows this flow:
+1. User attempts to access the application
+2. Application redirects to CAS login page
+3. User authenticates with MyID and password
+4. CAS validates credentials (may require ArchPass/2FA)
+5. CAS redirects back to application with a service ticket
+6. Application validates ticket with CAS server
+7. Application creates session for authenticated user
+
+### Implementation Options
+
+#### Option 1: Backend Proxy Approach (Recommended)
+- Implement CAS authentication on the backend (API server)
+- Frontend redirects to backend CAS endpoint
+- Backend handles CAS ticket validation
+- Backend creates Supabase session or JWT token
+- Frontend receives authentication token
+
+**Pros:**
+- Keeps CAS credentials secure on backend
+- Can integrate with existing Supabase auth
+- Better security model
+
+#### Option 2: Frontend CAS Client Library
+- Use a JavaScript CAS client library in the React app
+- Handle CAS redirect flow in the browser
+- Exchange ticket for user information
+
+**Pros:**
+- Simpler initial implementation
+- Direct integration in frontend
+
+**Cons:**
+- Requires exposing CAS service URL
+- May need backend validation anyway
+
+### CAS Client Libraries
+
+For JavaScript/TypeScript implementations, consider:
+- **cas-client** (npm) - CAS client for Node.js
+- **passport-cas** - Passport.js strategy for CAS
+- Custom implementation using CAS REST API
+
+### Integration with Supabase
+
+You have several options for integrating CAS with your existing Supabase setup:
+
+1. **CAS → Supabase User Mapping:**
+ - After CAS authentication, create/update Supabase user
+ - Map CAS user attributes (email, MyID) to Supabase user
+ - Use Supabase session for application access
+
+2. **Hybrid Authentication:**
+ - Support both CAS (UGA users) and email/password (external users)
+ - Allow users to choose authentication method
+
+3. **CAS-Only Authentication:**
+ - Replace Supabase auth entirely with CAS
+ - Use CAS session management
+ - Store user data in Supabase but authenticate via CAS
+
+## Key Information Needed from UGA
+
+After submitting your integration request, you'll need to obtain:
+
+1. **CAS Server URL** - The UGA CAS server endpoint
+2. **Service URL** - Your application's callback URL
+3. **CAS Protocol Version** - Which CAS protocol version UGA uses (CAS 2.0, CAS 3.0, etc.)
+4. **User Attributes** - What user information is returned (email, MyID, name, etc.)
+5. **Certificate/Validation Method** - How to validate CAS tickets
+6. **Two-Factor Authentication Requirements** - Whether ArchPass is required
+
+## Two-Factor Authentication
+
+UGA uses **ArchPass** (powered by Duo) for two-factor authentication. Depending on your application's security requirements:
+- Users may be required to complete 2FA during login
+- This is typically handled automatically by the CAS server
+- No additional implementation needed on your end
+
+## Identity Federation
+
+UGA supports **Identity Federation**, which allows:
+- Users from other organizations to access your application
+- Collaborative research projects across institutions
+- Single set of credentials for multiple applications
+
+If you need federated identity support, mention this in your integration request.
+
+## Next Steps
+
+1. **Immediate Actions:**
+ - [ ] Submit the UGA SSO Integration Request form
+ - [ ] Prepare application information (description, URL, security details)
+ - [ ] Review current authentication implementation
+
+2. **While Waiting for Approval:**
+ - [ ] Research CAS client libraries for your tech stack
+ - [ ] Design integration architecture (backend vs frontend)
+ - [ ] Plan user data mapping (CAS attributes → Supabase users)
+ - [ ] Consider hybrid authentication approach
+
+3. **After Approval:**
+ - [ ] Obtain CAS server details from UGA EITS
+ - [ ] Implement CAS authentication flow
+ - [ ] Test with UGA test accounts
+ - [ ] Complete security review process
+ - [ ] Deploy to production
+
+## Resources
+
+### UGA Official Resources
+- **UGA SSO Main Page:** https://eits.uga.edu/access_and_security/uga_sso/
+- **SSO Integration Request:** https://eits.uga.edu/access_and_security/uga_sso/moving_to_uga_sso/sso_integration_request/
+- **What is CAS?** https://eits.uga.edu/access_and_security/new_cas/what_is_cas/
+- **Request UGA SSO Support:** Available through EITS portal
+
+### CAS Documentation
+- **Apereo CAS Documentation:** https://apereo.github.io/cas/
+- **CAS Protocol Specification:** https://apereo.github.io/cas/development/protocol/CAS-Protocol-Specification.html
+
+### Technical References
+- **CAS REST API:** For programmatic CAS integration
+- **CAS Client Libraries:** Search npm for "cas-client" or "passport-cas"
+- **Supabase Auth Documentation:** For integrating external auth providers
+
+## Questions to Ask UGA EITS
+
+When you receive contact from UGA's Office of Information Security, consider asking:
+
+1. What is the CAS server URL for production?
+2. What CAS protocol version should we use?
+3. What user attributes are available in the CAS response?
+4. Is ArchPass (2FA) required for our application?
+5. What is the expected service URL format?
+6. Are there any specific security requirements we should be aware of?
+7. Is there a test/staging CAS environment for development?
+8. What is the expected timeline for the integration process?
+
+## Notes
+
+- The integration process requires official approval from UGA EITS
+- Security review is mandatory and may take some time
+- You may need to modify your application's security configuration
+- Consider maintaining backward compatibility with existing users during transition
+- Document the integration process for future maintenance
+
+---
+
+**Last Updated:** Based on research conducted in 2024
+**Contact:** UGA EITS for official documentation and support
+
+
+
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 bd30fc3..5d4efeb 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
@@ -39,3 +39,8 @@ ADD CONSTRAINT unique_meyer_cracker_parameters_per_repetition
+
+
+
+
+
diff --git a/management-dashboard-web-app/supabase/migrations/00014_experiments_with_reps_and_phases_view.sql b/management-dashboard-web-app/supabase/migrations/00014_experiments_with_reps_and_phases_view.sql
index 11d2f0a..ea0cd59 100644
--- a/management-dashboard-web-app/supabase/migrations/00014_experiments_with_reps_and_phases_view.sql
+++ b/management-dashboard-web-app/supabase/migrations/00014_experiments_with_reps_and_phases_view.sql
@@ -23,7 +23,7 @@ SELECT
ep.has_airdrying,
ep.has_cracking,
ep.has_shelling,
- ep.cracking_machine_type_id,
+ ep.cracking_machine_type_id as phase_cracking_machine_type_id,
-- Repetition fields
er.id as repetition_id,
diff --git a/scheduling-remote/REFACTORING_SUMMARY.md b/scheduling-remote/REFACTORING_SUMMARY.md
new file mode 100644
index 0000000..9a08faf
--- /dev/null
+++ b/scheduling-remote/REFACTORING_SUMMARY.md
@@ -0,0 +1,95 @@
+# Scheduling Component Refactoring Summary
+
+## Overview
+
+The `Scheduling.tsx` file (originally 1832 lines) has been refactored into a modular structure with reusable components, hooks, and better organization.
+
+## New Structure
+
+```
+scheduling-remote/src/components/
+├── Scheduling.tsx # Backward compatibility re-export
+└── scheduling/
+ ├── types.ts # Shared types and interfaces
+ ├── Scheduling.tsx # Main router component (~100 lines)
+ ├── AvailabilityCalendar.tsx # Availability calendar component
+ ├── ui/ # Reusable UI components
+ │ ├── BackButton.tsx # Back navigation button
+ │ ├── SchedulingCard.tsx # Card component for main view
+ │ ├── TimeSlotModal.tsx # Modal for adding time slots
+ │ └── DropdownCurtain.tsx # Reusable dropdown/accordion
+ ├── views/ # Main view components
+ │ ├── ViewSchedule.tsx # View schedule component
+ │ ├── IndicateAvailability.tsx # Indicate availability view
+ │ └── ScheduleExperimentImpl.tsx # Temporary wrapper (TODO: further refactor)
+ └── hooks/ # Custom hooks
+ └── useConductors.ts # Conductor management hook
+```
+
+## What Was Extracted
+
+### 1. **Shared Types** (`types.ts`)
+- `CalendarEvent` interface
+- `SchedulingProps` interface
+- `SchedulingView` type
+- `ScheduledRepetition` interface
+- Re-exports of service types
+
+### 2. **Reusable UI Components** (`ui/`)
+- **BackButton**: Consistent back navigation
+- **SchedulingCard**: Reusable card component for the main scheduling view
+- **TimeSlotModal**: Modal for adding/editing time slots
+- **DropdownCurtain**: Reusable accordion/dropdown component
+
+### 3. **View Components** (`views/`)
+- **ViewSchedule**: Complete schedule view (simplified, placeholder)
+- **IndicateAvailability**: Wrapper for availability calendar
+- **ScheduleExperimentImpl**: Temporary wrapper (original implementation still in old file)
+
+### 4. **Custom Hooks** (`hooks/`)
+- **useConductors**: Manages conductor data, selection, colors, and availability
+
+### 5. **Main Components**
+- **Scheduling.tsx**: Main router component (reduced from ~220 lines to ~100 lines)
+- **AvailabilityCalendar.tsx**: Extracted availability calendar (moved from inline component)
+
+## Benefits
+
+1. **Maintainability**: Each component has a single, clear responsibility
+2. **Reusability**: UI components can be reused across different views
+3. **Testability**: Smaller units are easier to test in isolation
+4. **Readability**: Easier to understand and navigate the codebase
+5. **Organization**: Clear separation of concerns
+
+## File Size Reduction
+
+- **Original Scheduling.tsx**: 1832 lines
+- **New main Scheduling.tsx**: ~100 lines (95% reduction)
+- **Total new structure**: Better organized across multiple focused files
+
+## Backward Compatibility
+
+The original `Scheduling.tsx` file is maintained for backward compatibility and re-exports the new modular component. The `ScheduleExperiment` function is still exported from the original file location.
+
+## TODO / Future Improvements
+
+1. **Further refactor ScheduleExperiment**:
+ - Extract into smaller subcomponents (ConductorPanel, ExperimentPhasePanel, etc.)
+ - Create additional hooks (useExperimentPhases, useScheduling, useCalendarEvents)
+ - Move implementation from old file to new structure
+
+2. **Additional reusable components**:
+ - Experiment item component
+ - Repetition item component
+ - Calendar controls component
+
+3. **Testing**:
+ - Add unit tests for extracted components
+ - Add integration tests for hooks
+
+## Migration Notes
+
+- All imports from `./components/Scheduling` continue to work
+- The new structure is in `./components/scheduling/`
+- No breaking changes to the public API
+
diff --git a/scheduling-remote/src/components/CalendarStyles.css b/scheduling-remote/src/components/CalendarStyles.css
index 38e0f6c..8d8d790 100644
--- a/scheduling-remote/src/components/CalendarStyles.css
+++ b/scheduling-remote/src/components/CalendarStyles.css
@@ -4,6 +4,9 @@
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);
+ height: 100%;
+ display: flex;
+ flex-direction: column;
}
/* Dark mode support */
@@ -65,6 +68,8 @@
.rbc-month-view {
border: 1px solid #e2e8f0;
border-radius: 8px;
+ flex: 1;
+ min-height: 0;
}
.dark .rbc-month-view {
@@ -73,6 +78,8 @@
.rbc-month-row {
border-bottom: 1px solid #e2e8f0;
+ flex: 1;
+ min-height: 100px;
}
.dark .rbc-month-row {
@@ -87,6 +94,10 @@
.rbc-time-view {
border: 1px solid #e2e8f0;
border-radius: 8px;
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
}
.dark .rbc-time-view {
@@ -105,6 +116,9 @@
.rbc-time-content {
background: white;
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
}
.dark .rbc-time-content {
diff --git a/scheduling-remote/src/components/HorizontalTimelineCalendar.tsx b/scheduling-remote/src/components/HorizontalTimelineCalendar.tsx
new file mode 100644
index 0000000..6f2744f
--- /dev/null
+++ b/scheduling-remote/src/components/HorizontalTimelineCalendar.tsx
@@ -0,0 +1,1169 @@
+import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react'
+import moment from 'moment'
+
+interface ConductorAvailability {
+ conductorId: string
+ conductorName: string
+ color: string
+ availability: Array<{
+ start: Date
+ end: Date
+ }>
+}
+
+interface PhaseMarker {
+ id: string
+ repetitionId: string
+ experimentId: string
+ phase: 'soaking' | 'airdrying' | 'cracking'
+ startTime: Date
+ assignedConductors: string[] // Array of conductor IDs
+ locked: boolean
+}
+
+interface HorizontalTimelineCalendarProps {
+ startDate: Date
+ endDate: Date
+ conductorAvailabilities: ConductorAvailability[]
+ phaseMarkers: PhaseMarker[]
+ onMarkerDrag: (markerId: string, newTime: Date) => void
+ onMarkerAssignConductors: (markerId: string, conductorIds: string[]) => void
+ onMarkerLockToggle: (markerId: string) => void
+ timeStep?: number // Minutes per pixel or time unit
+ minHour?: number
+ maxHour?: number
+ dayWidth?: number // Width of each day column in pixels (default: 200)
+}
+
+// Repetition border component with hover state and drag support
+function RepetitionBorder({
+ left,
+ width,
+ top = 0,
+ isLocked,
+ allPhases,
+ times,
+ assignedCount,
+ repId,
+ onMouseDown,
+ isDragging = false,
+ dragOffset = { x: 0 }
+}: {
+ left: number
+ width: number
+ top?: number
+ isLocked: boolean
+ allPhases: string
+ times: string
+ assignedCount: number
+ repId: string
+ onMouseDown?: (e: React.MouseEvent) => void
+ isDragging?: boolean
+ dragOffset?: { x: number }
+}) {
+ const [isHovered, setIsHovered] = useState(false)
+
+ return (
+
setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ onMouseDown={onMouseDown}
+ title={`Repetition ${repId}: ${allPhases} at ${times}${assignedCount > 0 ? ` (${assignedCount} conductors assigned)` : ''}`}
+ />
+ )
+}
+
+export function HorizontalTimelineCalendar({
+ startDate,
+ endDate,
+ conductorAvailabilities,
+ phaseMarkers,
+ onMarkerDrag,
+ onMarkerAssignConductors,
+ onMarkerLockToggle,
+ timeStep = 15, // 15 minutes per time slot
+ minHour = 6,
+ maxHour = 22,
+ dayWidth // Width per day in pixels (optional - if not provided, will be calculated)
+}: HorizontalTimelineCalendarProps) {
+ const CONDUCTOR_NAME_COLUMN_WIDTH = 128 // Width of conductor name column (w-32 = 128px)
+ const MIN_DAY_WIDTH = 150 // Minimum width per day column
+ const DEFAULT_DAY_WIDTH = 200 // Default width per day column
+
+ const [draggingMarker, setDraggingMarker] = useState(null)
+ const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
+ const [hoveredMarker, setHoveredMarker] = useState(null)
+ const [selectedMarker, setSelectedMarker] = useState(null)
+ const [assignmentPanelPosition, setAssignmentPanelPosition] = useState<{ x: number; y: number } | null>(null)
+ const [hoveredAvailability, setHoveredAvailability] = useState(null) // Format: "conductorId-availIndex"
+ const [hoveredVerticalLine, setHoveredVerticalLine] = useState(null) // Marker ID
+ const [draggingRepetition, setDraggingRepetition] = useState(null) // Repetition ID being dragged
+ const [repetitionDragOffset, setRepetitionDragOffset] = useState({ x: 0 })
+ const [dragPosition, setDragPosition] = useState<{ x: number } | null>(null) // Current drag position
+ const [containerWidth, setContainerWidth] = useState(0)
+ const timelineRef = useRef(null)
+ const assignmentPanelRef = useRef(null)
+ const scrollableContainersRef = useRef([])
+ const containerRef = useRef(null)
+
+ // Generate days between start and end date (must be before calculatedDayWidth)
+ const days = useMemo(() => {
+ const daysArray: Date[] = []
+ const current = new Date(startDate)
+ while (current <= endDate) {
+ daysArray.push(new Date(current))
+ current.setDate(current.getDate() + 1)
+ }
+ return daysArray
+ }, [startDate, endDate])
+
+ // Filter markers to only show those within the visible date range
+ // Markers outside the range are hidden but their data is preserved in phaseMarkers prop
+ // When zooming/navigating, markers will reappear if they fall within the new visible range
+ const visibleMarkers = useMemo(() => {
+ return phaseMarkers.filter(marker => {
+ const markerDate = new Date(marker.startTime)
+ markerDate.setHours(0, 0, 0, 0) // Compare by date only
+
+ const start = new Date(startDate)
+ start.setHours(0, 0, 0, 0)
+ const end = new Date(endDate)
+ end.setHours(0, 0, 0, 0)
+
+ // Check if marker's date falls within the visible date range
+ return markerDate >= start && markerDate <= end
+ })
+ }, [phaseMarkers, startDate, endDate])
+
+ // Calculate dynamic day width based on available space
+ const calculatedDayWidth = useMemo(() => {
+ // If container width hasn't been measured yet or no days, use default
+ if (containerWidth === 0 || days.length === 0) {
+ return DEFAULT_DAY_WIDTH
+ }
+
+ // Available width = container width - conductor name column width
+ const availableWidth = containerWidth - CONDUCTOR_NAME_COLUMN_WIDTH
+
+ // Ensure we have positive available width
+ if (availableWidth <= 0) {
+ return DEFAULT_DAY_WIDTH
+ }
+
+ const calculatedWidth = availableWidth / days.length
+
+ // Use calculated width if it's above minimum, otherwise use minimum
+ return Math.max(calculatedWidth, MIN_DAY_WIDTH)
+ }, [containerWidth, days.length])
+
+ // Use the provided dayWidth prop if explicitly set, otherwise use calculated width
+ const effectiveDayWidth = dayWidth !== undefined ? dayWidth : calculatedDayWidth
+
+ // Track container width for responsive day width calculation
+ useEffect(() => {
+ const updateWidth = () => {
+ if (containerRef.current) {
+ const width = containerRef.current.offsetWidth
+ if (width > 0) {
+ setContainerWidth(prevWidth => {
+ // Only update if width actually changed to avoid unnecessary re-renders
+ return width !== prevWidth ? width : prevWidth
+ })
+ }
+ }
+ }
+
+ // Initial measurement - try multiple times to ensure DOM is ready
+ const tryMeasure = () => {
+ if (containerRef.current && containerRef.current.offsetWidth > 0) {
+ updateWidth()
+ } else {
+ // Retry if not ready yet
+ setTimeout(tryMeasure, 10)
+ }
+ }
+
+ // Start measurement after a brief delay
+ const timeoutId = setTimeout(tryMeasure, 0)
+
+ window.addEventListener('resize', updateWidth)
+
+ // Use ResizeObserver for more accurate tracking
+ const resizeObserver = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ const width = entry.contentRect.width
+ if (width > 0) {
+ setContainerWidth(prevWidth => {
+ // Only update if width actually changed to avoid unnecessary re-renders
+ return width !== prevWidth ? width : prevWidth
+ })
+ }
+ }
+ })
+
+ // Observe after a brief delay to ensure ref is attached
+ const observeTimeout = setTimeout(() => {
+ if (containerRef.current) {
+ resizeObserver.observe(containerRef.current)
+ }
+ }, 0)
+
+ return () => {
+ clearTimeout(timeoutId)
+ clearTimeout(observeTimeout)
+ window.removeEventListener('resize', updateWidth)
+ resizeObserver.disconnect()
+ }
+ }, [])
+
+ // Sync scrolling across all scrollable containers
+ useEffect(() => {
+ const containers = document.querySelectorAll('.scroll-sync')
+ if (containers.length === 0) return
+
+ const handleScroll = (e: Event) => {
+ const target = e.target as HTMLElement
+ const scrollLeft = target.scrollLeft
+
+ containers.forEach((container) => {
+ if (container !== target) {
+ container.scrollLeft = scrollLeft
+ }
+ })
+ }
+
+ containers.forEach((container) => {
+ container.addEventListener('scroll', handleScroll)
+ })
+
+ return () => {
+ containers.forEach((container) => {
+ container.removeEventListener('scroll', handleScroll)
+ })
+ }
+ }, [days.length]) // Re-run when days change
+
+ // Close assignment panel when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ selectedMarker &&
+ assignmentPanelRef.current &&
+ !assignmentPanelRef.current.contains(event.target as Node) &&
+ !(event.target as HTMLElement).closest('[data-marker-id]') &&
+ !(event.target as HTMLElement).closest('button[title="Assign Conductors"]')
+ ) {
+ setSelectedMarker(null)
+ setAssignmentPanelPosition(null)
+ }
+ }
+
+ if (selectedMarker && assignmentPanelPosition) {
+ document.addEventListener('mousedown', handleClickOutside)
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside)
+ }
+ }
+ }, [selectedMarker, assignmentPanelPosition])
+
+ // Generate time slots for a day
+ const timeSlots = useMemo(() => {
+ const slots: string[] = []
+ for (let hour = minHour; hour < maxHour; hour++) {
+ for (let minute = 0; minute < 60; minute += timeStep) {
+ slots.push(`${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`)
+ }
+ }
+ return slots
+ }, [minHour, maxHour, timeStep])
+
+ // Calculate pixel position for a given date/time
+ const getTimePosition = useCallback((date: Date): number => {
+ const dayIndex = days.findIndex(d =>
+ d.toDateString() === date.toDateString()
+ )
+ if (dayIndex === -1) return 0
+
+ const dayStart = new Date(date)
+ dayStart.setHours(minHour, 0, 0, 0)
+
+ const minutesFromStart = (date.getTime() - dayStart.getTime()) / (1000 * 60)
+ const slotIndex = minutesFromStart / timeStep // Use fractional for smoother positioning
+
+ const slotWidth = effectiveDayWidth / (timeSlots.length)
+
+ return dayIndex * effectiveDayWidth + slotIndex * slotWidth
+ }, [days, timeSlots, minHour, timeStep, effectiveDayWidth])
+
+ // Convert pixel position to date/time
+ const getTimeFromPosition = useCallback((x: number, dayIndex: number): Date => {
+ const dayStart = new Date(days[dayIndex])
+ dayStart.setHours(minHour, 0, 0, 0)
+
+ const relativeX = x - (dayIndex * effectiveDayWidth)
+ const slotWidth = effectiveDayWidth / timeSlots.length
+ const slotIndex = Math.max(0, Math.min(timeSlots.length - 1, Math.floor(relativeX / slotWidth)))
+
+ const minutes = slotIndex * timeStep
+ const result = new Date(dayStart)
+ result.setMinutes(result.getMinutes() + minutes)
+
+ return result
+ }, [days, timeSlots, minHour, timeStep, effectiveDayWidth])
+
+ // Handle marker drag start - treat as repetition drag
+ const handleMarkerMouseDown = useCallback((e: React.MouseEvent, markerId: string) => {
+ e.preventDefault()
+ e.stopPropagation()
+ const marker = phaseMarkers.find(m => m.id === markerId)
+ if (!marker || marker.locked) return
+
+ // Find all markers in this repetition
+ const markers = phaseMarkers.filter(m => m.repetitionId === marker.repetitionId)
+ if (markers.some(m => m.locked)) return
+
+ if (!timelineRef.current) return
+
+ const scrollLeft = timelineRef.current.scrollLeft
+ const timelineRect = timelineRef.current.getBoundingClientRect()
+
+ // Get the leftmost marker position to use as reference
+ const leftmostMarker = markers.reduce((prev, curr) =>
+ new Date(prev.startTime).getTime() < new Date(curr.startTime).getTime() ? prev : curr
+ )
+ const leftmostX = getTimePosition(leftmostMarker.startTime)
+ const borderPadding = 20
+ const borderLeft = leftmostX - borderPadding
+
+ // Calculate offset from mouse to border left edge in timeline coordinates
+ const mouseXInTimeline = e.clientX - timelineRect.left - CONDUCTOR_NAME_COLUMN_WIDTH + scrollLeft
+ const offsetX = mouseXInTimeline - borderLeft
+
+ setDraggingRepetition(marker.repetitionId)
+ setRepetitionDragOffset({
+ x: offsetX
+ })
+ setDragPosition({ x: borderLeft })
+ }, [phaseMarkers, getTimePosition])
+
+ // Handle repetition border drag start
+ const handleRepetitionMouseDown = useCallback((e: React.MouseEvent, repetitionId: string) => {
+ e.preventDefault()
+ e.stopPropagation()
+
+ const markers = phaseMarkers.filter(m => m.repetitionId === repetitionId)
+ if (markers.length === 0 || markers.some(m => m.locked)) return
+
+ if (!timelineRef.current) return
+
+ const borderElement = e.currentTarget as HTMLElement
+ const borderLeft = parseFloat(borderElement.style.left) || 0
+ const scrollLeft = timelineRef.current.scrollLeft
+ const timelineRect = timelineRef.current.getBoundingClientRect()
+
+ // Calculate offset from mouse to left edge of border in timeline coordinates
+ const mouseXInTimeline = e.clientX - timelineRect.left - CONDUCTOR_NAME_COLUMN_WIDTH + scrollLeft
+ const offsetX = mouseXInTimeline - borderLeft
+
+ setDraggingRepetition(repetitionId)
+ setRepetitionDragOffset({
+ x: offsetX
+ })
+ setDragPosition({ x: borderLeft })
+ }, [phaseMarkers])
+
+ // Handle mouse move during drag - only update visual position, save on mouse up
+ useEffect(() => {
+ if (!draggingRepetition) return
+
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!timelineRef.current) return
+
+ // Get the scrollable container (first scroll-sync element)
+ const scrollContainer = timelineRef.current
+ const rect = scrollContainer.getBoundingClientRect()
+ const scrollLeft = scrollContainer.scrollLeft
+
+ // Drag entire repetition horizontally only - only update visual position
+ const mouseXInTimeline = e.clientX - rect.left - CONDUCTOR_NAME_COLUMN_WIDTH + scrollLeft
+ const borderX = mouseXInTimeline - repetitionDragOffset.x
+
+ // Update visual position during drag (don't call onMarkerDrag here to avoid conflicts)
+ setDragPosition({ x: borderX })
+ }
+
+ const handleMouseUp = (e: MouseEvent) => {
+ // Only update on mouse up to ensure clean state update
+ if (timelineRef.current && draggingRepetition) {
+ const scrollContainer = timelineRef.current
+ const rect = scrollContainer.getBoundingClientRect()
+ const scrollLeft = scrollContainer.scrollLeft
+ const mouseXInTimeline = e.clientX - rect.left - CONDUCTOR_NAME_COLUMN_WIDTH + scrollLeft
+ const borderX = mouseXInTimeline - repetitionDragOffset.x
+
+ const markers = visibleMarkers.filter(m => m.repetitionId === draggingRepetition)
+ if (markers.length > 0) {
+ const leftmostMarker = markers.reduce((prev, curr) =>
+ new Date(prev.startTime).getTime() < new Date(curr.startTime).getTime() ? prev : curr
+ )
+
+ const borderPadding = 20
+ const leftmostMarkerNewX = borderX + borderPadding
+ const dayIndex = Math.max(0, Math.min(days.length - 1, Math.floor(leftmostMarkerNewX / effectiveDayWidth)))
+ const relativeX = leftmostMarkerNewX - (dayIndex * effectiveDayWidth)
+ const slotWidth = effectiveDayWidth / timeSlots.length
+ const slotIndex = Math.max(0, Math.min(timeSlots.length - 1, Math.round(relativeX / slotWidth)))
+
+ const dayStart = new Date(days[dayIndex])
+ dayStart.setHours(minHour, 0, 0, 0)
+ const minutes = slotIndex * timeStep
+ const finalTime = new Date(dayStart)
+ finalTime.setMinutes(finalTime.getMinutes() + minutes)
+
+ // Update only once on mouse up
+ console.log('Updating marker position:', leftmostMarker.id, finalTime)
+ onMarkerDrag(leftmostMarker.id, finalTime)
+ }
+ }
+
+ // Clear drag state
+ setDraggingRepetition(null)
+ setRepetitionDragOffset({ x: 0 })
+ setDragPosition(null)
+ }
+
+ window.addEventListener('mousemove', handleMouseMove)
+ window.addEventListener('mouseup', handleMouseUp)
+
+ return () => {
+ window.removeEventListener('mousemove', handleMouseMove)
+ window.removeEventListener('mouseup', handleMouseUp)
+ }
+ }, [draggingRepetition, repetitionDragOffset, days, timeSlots, minHour, timeStep, effectiveDayWidth, onMarkerDrag, visibleMarkers, phaseMarkers, getTimePosition])
+
+ // Get phase marker color and icon
+ const getPhaseStyle = (phase: string) => {
+ switch (phase) {
+ case 'soaking':
+ return { color: '#3b82f6', icon: '💧', label: 'Soaking' }
+ case 'airdrying':
+ return { color: '#10b981', icon: '🌬️', label: 'Airdrying' }
+ case 'cracking':
+ return { color: '#f59e0b', icon: '⚡', label: 'Cracking' }
+ default:
+ return { color: '#6b7280', icon: '•', label: 'Phase' }
+ }
+ }
+
+ // Get markers for a specific day
+ const getMarkersForDay = (day: Date) => {
+ return phaseMarkers.filter(marker => {
+ const markerDate = new Date(marker.startTime)
+ return markerDate.toDateString() === day.toDateString()
+ })
+ }
+
+ // Get availability lines for a conductor on a specific day
+ const getAvailabilityForDay = (conductor: ConductorAvailability, day: Date) => {
+ return conductor.availability.filter(avail => {
+ const availDate = new Date(avail.start)
+ return availDate.toDateString() === day.toDateString()
+ })
+ }
+
+ return (
+
- 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.
-
+ )
+}
+
diff --git a/scheduling-remote/src/components/scheduling/views/IndicateAvailability.tsx b/scheduling-remote/src/components/scheduling/views/IndicateAvailability.tsx
new file mode 100644
index 0000000..e57392a
--- /dev/null
+++ b/scheduling-remote/src/components/scheduling/views/IndicateAvailability.tsx
@@ -0,0 +1,29 @@
+import type { User } from '../types'
+import { BackButton } from '../ui/BackButton'
+import { AvailabilityCalendar } from '../AvailabilityCalendar'
+
+interface IndicateAvailabilityProps {
+ user: User
+ onBack: () => void
+}
+
+export function IndicateAvailability({ user, onBack }: IndicateAvailabilityProps) {
+ return (
+
+
+
+
+ Indicate Availability
+
+
+ Set your availability preferences and time slots for upcoming experiment runs.
+
+
+
+
+
+
+
+ )
+}
+
diff --git a/scheduling-remote/src/components/scheduling/views/ScheduleExperiment.tsx b/scheduling-remote/src/components/scheduling/views/ScheduleExperiment.tsx
new file mode 100644
index 0000000..11126dc
--- /dev/null
+++ b/scheduling-remote/src/components/scheduling/views/ScheduleExperiment.tsx
@@ -0,0 +1,6 @@
+// This is a temporary wrapper that imports the original ScheduleExperiment logic
+// TODO: Further refactor this component into smaller subcomponents and hooks
+// For now, we're keeping the original implementation but moving it to its own file
+
+export { ScheduleExperiment } from './ScheduleExperimentImpl'
+
diff --git a/scheduling-remote/src/components/scheduling/views/ScheduleExperimentImpl.tsx b/scheduling-remote/src/components/scheduling/views/ScheduleExperimentImpl.tsx
new file mode 100644
index 0000000..7e59ec5
--- /dev/null
+++ b/scheduling-remote/src/components/scheduling/views/ScheduleExperimentImpl.tsx
@@ -0,0 +1,9 @@
+// Temporary implementation - imports the original ScheduleExperiment from the old file
+// TODO: Further refactor this into smaller components and hooks
+// For now, we're keeping it in the original file but will move it here later
+
+import type { User } from '../types'
+
+// Re-export the original implementation from the old file location
+export { ScheduleExperiment } from '../../Scheduling'
+
diff --git a/scheduling-remote/src/components/scheduling/views/ViewSchedule.tsx b/scheduling-remote/src/components/scheduling/views/ViewSchedule.tsx
new file mode 100644
index 0000000..53deba7
--- /dev/null
+++ b/scheduling-remote/src/components/scheduling/views/ViewSchedule.tsx
@@ -0,0 +1,41 @@
+import type { User } from '../types'
+import { BackButton } from '../ui/BackButton'
+
+interface ViewScheduleProps {
+ user: User
+ onBack: () => void
+}
+
+export function ViewSchedule({ user, onBack }: ViewScheduleProps) {
+ 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.
+