From 6cf67822dcae0e752f7393395f8c8706100b82f1 Mon Sep 17 00:00:00 2001 From: salirezav Date: Thu, 18 Dec 2025 14:33:05 -0500 Subject: [PATCH] Commit changes before merging to main --- .../usda_vision_system/camera/recorder.py | 99 +- docs/UGA_SSO_Integration_Guide.md | 199 ++ ...dd_repetition_id_to_cracker_parameters.sql | 5 + ..._experiments_with_reps_and_phases_view.sql | 2 +- scheduling-remote/REFACTORING_SUMMARY.md | 95 + .../src/components/CalendarStyles.css | 14 + .../components/HorizontalTimelineCalendar.tsx | 1169 ++++++++ .../src/components/Scheduling.tsx | 2482 ++++++++--------- .../scheduling/AvailabilityCalendar.tsx | 218 ++ .../src/components/scheduling/Scheduling.tsx | 120 + .../scheduling/hooks/useConductors.ts | 123 + .../src/components/scheduling/types.ts | 29 + .../components/scheduling/ui/BackButton.tsx | 19 + .../scheduling/ui/DropdownCurtain.tsx | 35 + .../scheduling/ui/SchedulingCard.tsx | 65 + .../scheduling/ui/TimeSlotModal.tsx | 123 + .../scheduling/views/IndicateAvailability.tsx | 29 + .../scheduling/views/ScheduleExperiment.tsx | 6 + .../views/ScheduleExperimentImpl.tsx | 9 + .../scheduling/views/ViewSchedule.tsx | 41 + 20 files changed, 3571 insertions(+), 1311 deletions(-) create mode 100644 docs/UGA_SSO_Integration_Guide.md create mode 100644 scheduling-remote/REFACTORING_SUMMARY.md create mode 100644 scheduling-remote/src/components/HorizontalTimelineCalendar.tsx create mode 100644 scheduling-remote/src/components/scheduling/AvailabilityCalendar.tsx create mode 100644 scheduling-remote/src/components/scheduling/Scheduling.tsx create mode 100644 scheduling-remote/src/components/scheduling/hooks/useConductors.ts create mode 100644 scheduling-remote/src/components/scheduling/types.ts create mode 100644 scheduling-remote/src/components/scheduling/ui/BackButton.tsx create mode 100644 scheduling-remote/src/components/scheduling/ui/DropdownCurtain.tsx create mode 100644 scheduling-remote/src/components/scheduling/ui/SchedulingCard.tsx create mode 100644 scheduling-remote/src/components/scheduling/ui/TimeSlotModal.tsx create mode 100644 scheduling-remote/src/components/scheduling/views/IndicateAvailability.tsx create mode 100644 scheduling-remote/src/components/scheduling/views/ScheduleExperiment.tsx create mode 100644 scheduling-remote/src/components/scheduling/views/ScheduleExperimentImpl.tsx create mode 100644 scheduling-remote/src/components/scheduling/views/ViewSchedule.tsx 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 ( +
+
+ {/* Vertical lines layer - positioned outside overflow containers */} +
+ {(() => { + // Group markers by repetition to calculate row positions + const markersByRepetition: Record = {} + phaseMarkers.forEach(marker => { + if (!markersByRepetition[marker.repetitionId]) { + markersByRepetition[marker.repetitionId] = [] + } + markersByRepetition[marker.repetitionId].push(marker) + }) + + const borderPadding = 20 + const repetitionData = Object.entries(markersByRepetition).map(([repId, markers]) => { + const positions = markers.map(m => { + const dayIndex = days.findIndex(d => + d.toDateString() === new Date(m.startTime).toDateString() + ) + if (dayIndex === -1) return null + return getTimePosition(m.startTime) + }).filter((p): p is number => p !== null) + + if (positions.length === 0) return null + + const leftmost = Math.min(...positions) + const rightmost = Math.max(...positions) + const borderLeft = leftmost - borderPadding + const borderRight = borderLeft + (rightmost - leftmost) + (borderPadding * 2) + + return { repId, markers, left: borderLeft, right: borderRight } + }).filter((d): d is NonNullable => d !== null) + + const ROW_HEIGHT = 40 + const sortedRepetitions = [...repetitionData].sort((a, b) => a.left - b.left) + const repetitionRows: Array> = [] + + sortedRepetitions.forEach(rep => { + let placed = false + for (let rowIndex = 0; rowIndex < repetitionRows.length; rowIndex++) { + const row = repetitionRows[rowIndex] + const hasOverlap = row.some(existingRep => { + const threshold = 1 + return !(rep.right + threshold <= existingRep.left || rep.left - threshold >= existingRep.right) + }) + + if (!hasOverlap) { + row.push(rep) + placed = true + break + } + } + + if (!placed) { + repetitionRows.push([rep]) + } + }) + + const repIdToRowIndex: Record = {} + repetitionRows.forEach((row, rowIndex) => { + row.forEach(rep => { + repIdToRowIndex[rep.repId] = rowIndex + }) + }) + + return visibleMarkers.map((marker) => { + const style = getPhaseStyle(marker.phase) + const dayIndex = days.findIndex(d => + d.toDateString() === new Date(marker.startTime).toDateString() + ) + if (dayIndex === -1) return null + + const absoluteX = getTimePosition(marker.startTime) + const isDragging = draggingRepetition === marker.repetitionId + const isVerticalLineHovered = hoveredVerticalLine === marker.id + const rowIndex = repIdToRowIndex[marker.repetitionId] || 0 + + const HEADER_ROW_HEIGHT = 60 + const CONDUCTOR_ROWS_HEIGHT = conductorAvailabilities.length * 36 + const MARKER_TOP_OFFSET = 10 + const MARKER_ICON_SIZE = 32 + const markerCenterY = MARKER_TOP_OFFSET + (MARKER_ICON_SIZE / 2) + const markerRowTop = HEADER_ROW_HEIGHT + CONDUCTOR_ROWS_HEIGHT + (rowIndex * ROW_HEIGHT) + const markerCenterAbsoluteY = markerRowTop + markerCenterY + const lineTop = HEADER_ROW_HEIGHT + const lineHeight = markerCenterAbsoluteY - HEADER_ROW_HEIGHT + + // Calculate line position - if dragging, adjust based on drag position + let lineX = absoluteX + if (isDragging && dragPosition) { + const repData = repetitionData.find(r => r.repId === marker.repetitionId) + if (repData) { + const offsetFromLeftmost = absoluteX - (repData.left + borderPadding) + lineX = dragPosition.x + borderPadding + offsetFromLeftmost + } + } + + return ( +
setHoveredVerticalLine(marker.id)} + onMouseLeave={() => setHoveredVerticalLine(null)} + title={moment(marker.startTime).format('h:mm A')} + /> + ) + }) + })()} +
+ + {/* Row 1: Day Headers */} +
+ {/* Fixed spacer column to align with conductor names column */} +
+ {/* Scrollable day columns container */} +
+ {/* Fixed width based only on visible days - never extends */} +
+ {days.map((day, index) => ( +
+
+ {moment(day).format('ddd, MMM D')} +
+
+ {moment(day).format('YYYY')} +
+
+ ))} +
+
+
+ + {/* Row 2: Conductor Availability */} +
+ {conductorAvailabilities.map((conductor, conductorIndex) => ( +
+ {/* Fixed conductor name column */} +
+
+
+ + {conductor.conductorName} + +
+
+ + {/* Scrollable timeline for this conductor */} +
+ {/* Fixed width based only on visible days - never extends */} +
+ {/* Day column borders */} + {days.map((day, dayIndex) => ( +
+ ))} + + {/* Render availability lines across all days */} + {conductor.availability.map((avail, availIndex) => { + // Get absolute positions from start of timeline + const startPos = getTimePosition(avail.start) + const endPos = getTimePosition(avail.end) + const width = endPos - startPos + + // Skip if width is invalid or too small + if (width <= 0 || isNaN(startPos) || isNaN(endPos)) return null + + // Check if availability overlaps with visible date range + const availStart = new Date(avail.start) + const availEnd = new Date(avail.end) + const isVisible = days.some(day => { + const dayStart = new Date(day) + dayStart.setHours(0, 0, 0, 0) + const dayEnd = new Date(day) + dayEnd.setHours(23, 59, 59, 999) + return availStart <= dayEnd && availEnd >= dayStart + }) + + if (!isVisible) return null + + const availabilityKey = `${conductor.conductorId}-${availIndex}` + const isHovered = hoveredAvailability === availabilityKey + + return ( +
setHoveredAvailability(availabilityKey)} + onMouseLeave={() => setHoveredAvailability(null)} + title={isHovered ? `${moment(avail.start).format('h:mm A')} - ${moment(avail.end).format('h:mm A')}` : `${conductor.conductorName}: ${moment(avail.start).format('MMM D, h:mm A')} - ${moment(avail.end).format('MMM D, h:mm A')}`} + /> + ) + })} +
+
+
+ ))} +
+ + {/* Row 3: Phase Markers - with multiple sub-rows for stacking */} +
+ {/* Fixed spacer to align with conductor names column */} +
+ {/* Scrollable background time grid */} +
+ {/* Fixed width based only on visible days - never extends */} +
+ {days.map((day, dayIndex) => ( +
+ ))} + + {/* Group markers by repetition ID and calculate vertical stacking */} + {(() => { + // Group only visible markers by repetition ID + const markersByRepetition: Record = {} + visibleMarkers.forEach(marker => { + if (!markersByRepetition[marker.repetitionId]) { + markersByRepetition[marker.repetitionId] = [] + } + markersByRepetition[marker.repetitionId].push(marker) + }) + + // Calculate positions for each repetition + const borderPadding = 20 + const repetitionData = Object.entries(markersByRepetition).map(([repId, markers]) => { + const positions = markers.map(m => { + const dayIndex = days.findIndex(d => + d.toDateString() === new Date(m.startTime).toDateString() + ) + if (dayIndex === -1) return null + return getTimePosition(m.startTime) + }).filter((p): p is number => p !== null) + + if (positions.length === 0) return null + + const leftmost = Math.min(...positions) + const rightmost = Math.max(...positions) + const borderLeft = leftmost - borderPadding + const borderWidth = (rightmost - leftmost) + (borderPadding * 2) + const borderRight = borderLeft + borderWidth + + return { + repId, + markers, + left: borderLeft, + right: borderRight, + width: borderWidth, + leftmostMarkerPos: leftmost, + rightmostMarkerPos: rightmost + } + }).filter((d): d is NonNullable => d !== null) + + // Calculate vertical stacking positions + // Sort repetitions by left position to process them in order + const sortedRepetitions = [...repetitionData].sort((a, b) => a.left - b.left) + const ROW_HEIGHT = 40 // Height allocated per row + const repetitionRows: Array> = [] + + sortedRepetitions.forEach(rep => { + // Find the first row where this repetition doesn't overlap + let placed = false + for (let rowIndex = 0; rowIndex < repetitionRows.length; rowIndex++) { + const row = repetitionRows[rowIndex] + // Check if this repetition overlaps with any in this row + // Two repetitions overlap if they share any horizontal space + // They don't overlap if one is completely to the left or right of the other + const hasOverlap = row.some(existingRep => { + // Add a small threshold to avoid edge cases + const threshold = 1 + return !(rep.right + threshold <= existingRep.left || rep.left - threshold >= existingRep.right) + }) + + if (!hasOverlap) { + row.push(rep) + placed = true + break + } + } + + // If no row found, create a new row + if (!placed) { + repetitionRows.push([rep]) + } + }) + + // Render all repetitions with their vertical positions + // Use flexbox to stack rows and share vertical space equally + return ( +
+ {repetitionRows.map((row, rowIndex) => ( +
+ {row.map(rep => { + const firstMarker = rep.markers[0] + const allPhases = rep.markers.map(m => getPhaseStyle(m.phase).label).join(', ') + const times = rep.markers.map(m => moment(m.startTime).format('h:mm A')).join(', ') + const totalAssigned = new Set(rep.markers.flatMap(m => m.assignedConductors)).size + + return ( + handleRepetitionMouseDown(e, rep.repId)} + isDragging={draggingRepetition === rep.repId} + dragOffset={repetitionDragOffset} + /> + ) + })} +
+ ))} +
+ ) + })()} + + {/* Phase markers - positioned relative to their repetition's row */} + {(() => { + // Group only visible markers by repetition to find their row positions + const markersByRepetition: Record = {} + visibleMarkers.forEach(marker => { + if (!markersByRepetition[marker.repetitionId]) { + markersByRepetition[marker.repetitionId] = [] + } + markersByRepetition[marker.repetitionId].push(marker) + }) + + // Calculate repetition positions and rows (same logic as borders) + const borderPadding = 20 + const repetitionData = Object.entries(markersByRepetition).map(([repId, markers]) => { + const positions = markers.map(m => { + const dayIndex = days.findIndex(d => + d.toDateString() === new Date(m.startTime).toDateString() + ) + if (dayIndex === -1) return null + return getTimePosition(m.startTime) + }).filter((p): p is number => p !== null) + + if (positions.length === 0) return null + + const leftmost = Math.min(...positions) + const rightmost = Math.max(...positions) + const borderLeft = leftmost - borderPadding + const borderWidth = (rightmost - leftmost) + (borderPadding * 2) + const borderRight = borderLeft + borderWidth + + return { + repId, + markers, + left: borderLeft, + right: borderRight, + width: borderWidth + } + }).filter((d): d is NonNullable => d !== null) + + // Calculate vertical stacking (same as borders) + const ROW_HEIGHT = 40 + const sortedRepetitions = [...repetitionData].sort((a, b) => a.left - b.left) + const repetitionRows: Array> = [] + + sortedRepetitions.forEach(rep => { + let placed = false + for (let rowIndex = 0; rowIndex < repetitionRows.length; rowIndex++) { + const row = repetitionRows[rowIndex] + const hasOverlap = row.some(existingRep => { + const threshold = 1 + return !(rep.right + threshold <= existingRep.left || rep.left - threshold >= existingRep.right) + }) + + if (!hasOverlap) { + row.push(rep) + placed = true + break + } + } + + if (!placed) { + repetitionRows.push([rep]) + } + }) + + // Create a map of repetition ID to row index + const repIdToRowIndex: Record = {} + repetitionRows.forEach((row, rowIndex) => { + row.forEach(rep => { + repIdToRowIndex[rep.repId] = rowIndex + }) + }) + + return visibleMarkers.map((marker) => { + const style = getPhaseStyle(marker.phase) + const dayIndex = days.findIndex(d => + d.toDateString() === new Date(marker.startTime).toDateString() + ) + if (dayIndex === -1) return null + + // Get absolute position from start of timeline (includes day offset) + const absoluteX = getTimePosition(marker.startTime) + const isDragging = draggingRepetition === marker.repetitionId + const isSelected = selectedMarker === marker.id + const rowIndex = repIdToRowIndex[marker.repetitionId] || 0 + const topOffset = rowIndex * ROW_HEIGHT + 10 // 10px padding from top of row + + const isVerticalLineHovered = hoveredVerticalLine === marker.id + + // Calculate marker position - if dragging, maintain relative position to border + let markerLeft = absoluteX + if (isDragging && dragPosition) { + const repData = repetitionData.find(r => r.repId === marker.repetitionId) + if (repData) { + // Calculate offset from leftmost marker + const leftmostMarker = repData.markers.reduce((prev, curr) => + getTimePosition(prev.startTime) < getTimePosition(curr.startTime) ? prev : curr + ) + const leftmostX = getTimePosition(leftmostMarker.startTime) + const offsetFromLeftmost = absoluteX - leftmostX + // Position relative to dragged border + markerLeft = dragPosition.x + borderPadding + offsetFromLeftmost + } + } + + return ( +
handleMarkerMouseDown(e, marker.id)} + onMouseEnter={() => { + setHoveredMarker(marker.id) + setHoveredVerticalLine(marker.id) + }} + onMouseLeave={() => { + setHoveredMarker(null) + setHoveredVerticalLine(null) + }} + title={`${style.label} - ${moment(marker.startTime).format('MMM D, h:mm A')}${marker.assignedConductors.length > 0 ? ` (${marker.assignedConductors.length} assigned)` : ''}`} + > + {/* Small icon marker */} +
+ + {style.icon} + + + {/* Assign Conductors button - top right corner */} + +
+ + {/* Connection indicators on assigned conductors (shown as dots on the vertical line) */} + {marker.assignedConductors.map((conductorId, lineIndex) => { + const conductorIndex = conductorAvailabilities.findIndex(c => c.conductorId === conductorId) + if (conductorIndex === -1) return null + + const CONDUCTOR_ROW_HEIGHT = 36 // Height of each conductor row + const HEADER_ROW_HEIGHT = 60 // Approximate height of header row + const conductorRowTop = HEADER_ROW_HEIGHT + (conductorIndex * CONDUCTOR_ROW_HEIGHT) + const conductorRowCenter = conductorRowTop + (CONDUCTOR_ROW_HEIGHT / 2) + + // Position dot at conductor row center (negative because it's above the marker) + // Distance from marker center to conductor row center + const dotY = -(totalHeightToMarker - conductorRowCenter) + + return ( +
+ ) + })} +
+ ) + }) + })()} +
+
+ + {/* Conductor assignment panel (shown when marker is selected) */} + {selectedMarker && assignmentPanelPosition && ( +
+
+

+ Assign Conductors +

+ +
+
+ {conductorAvailabilities.map((conductor) => { + const marker = phaseMarkers.find(m => m.id === selectedMarker) + const isAssigned = marker?.assignedConductors.includes(conductor.conductorId) || false + + return ( +
+ )} +
+
+
+ ) +} + diff --git a/scheduling-remote/src/components/Scheduling.tsx b/scheduling-remote/src/components/Scheduling.tsx index d50e09e..81c16ee 100644 --- a/scheduling-remote/src/components/Scheduling.tsx +++ b/scheduling-remote/src/components/Scheduling.tsx @@ -1,3 +1,7 @@ +// This file is kept for backward compatibility +// The main Scheduling component has been moved to ./scheduling/Scheduling.tsx +// This file re-exports it and also contains the original ScheduleExperiment implementation + import { useEffect, useState, useMemo, useCallback, useRef } from 'react' // @ts-ignore - react-big-calendar types not available import { Calendar, momentLocalizer, Views } from 'react-big-calendar' @@ -10,6 +14,10 @@ import type { User, ExperimentPhase, Experiment, ExperimentRepetition, Soaking, import { availabilityManagement, userManagement, experimentPhaseManagement, experimentManagement, repetitionManagement, phaseManagement, supabase } from '../services/supabase' import 'react-big-calendar/lib/css/react-big-calendar.css' import './CalendarStyles.css' +import { HorizontalTimelineCalendar } from './HorizontalTimelineCalendar' + +// Re-export the new modular Scheduling component +export { Scheduling } from './scheduling/Scheduling' // Type definitions for calendar events interface CalendarEvent { @@ -20,1390 +28,1264 @@ interface CalendarEvent { resource?: string } -interface SchedulingProps { - user: User - currentRoute: string -} +// Keep the original ScheduleExperiment implementation here for now +// TODO: Move this to scheduling/views/ScheduleExperimentImpl.tsx and further refactor +export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () => void }) { + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) -type SchedulingView = 'main' | 'view-schedule' | 'indicate-availability' | 'schedule-experiment' + const [conductors, setConductors] = useState([]) + const [conductorIdsWithFutureAvailability, setConductorIdsWithFutureAvailability] = useState>(new Set()) + const [selectedConductorIds, setSelectedConductorIds] = useState>(new Set()) -export function Scheduling({ user, currentRoute }: SchedulingProps) { - // Extract current view from route - const getCurrentView = (): SchedulingView => { - if (currentRoute === '/scheduling') { - return 'main' + 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()) + // Track conductor assignments for each phase marker (markerId -> conductorIds[]) + const [conductorAssignments, setConductorAssignments] = useState>({}) + // Horizontal calendar state: zoom level (number of days to show) and current view date + const [calendarZoom, setCalendarZoom] = useState(3) // Number of days to display + const [calendarStartDate, setCalendarStartDate] = useState(() => { + const today = new Date() + today.setHours(0, 0, 0, 0) + return today + }) + + // Ref for calendar container to preserve scroll position + const calendarRef = useRef(null) + const scrollPositionRef = useRef<{ scrollTop: number; scrollLeft: number } | null>(null) + + useEffect(() => { + const load = async () => { + try { + setLoading(true) + setError(null) + + const [allUsers, allPhases] = await Promise.all([ + userManagement.getAllUsers(), + experimentPhaseManagement.getAllExperimentPhases() + ]) + + // Filter conductors + const conductorsOnly = allUsers.filter(u => u.roles.includes('conductor')) + setConductors(conductorsOnly) + + // For each conductor, check if they have any availability in the future + const conductorIds = conductorsOnly.map(c => c.id) + setConductorIdsWithFutureAvailability(new Set(conductorIds)) + + setPhases(allPhases) + } catch (e: any) { + setError(e?.message || 'Failed to load scheduling data') + } finally { + setLoading(false) + } + } + load() + }, []) + + const toggleConductor = (id: string) => { + setSelectedConductorIds(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) } - const 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' + + 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) => { + 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) { + // 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') + } } } - return 'main' - } - const currentView = getCurrentView() + 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 + // Note: reStaggerRepetitions will automatically skip locked repetitions + reStaggerRepetitions([...next, repId]) + } + return next + }) + } - 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 toggleAllRepetitionsInPhase = (phaseId: string) => { + const experiments = experimentsByPhase[phaseId] || [] + const allRepetitions = experiments.flatMap(exp => repetitionsByExperiment[exp.id] || []) - const handleBackToMain = () => { - window.history.pushState({}, '', '/scheduling') - // Trigger a popstate event to update the route - window.dispatchEvent(new PopStateEvent('popstate')) - } + setSelectedRepetitionIds(prev => { + // Check if all repetitions in this phase are selected + const allSelected = allRepetitions.every(rep => prev.has(rep.id)) - // Render different views based on currentView - if (currentView === 'view-schedule') { - return - } + 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 + } + }) + } - 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()) - - - // 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 () => { + const createRepetitionsForExperiment = async (experimentId: string) => { try { - setLoading(true) + setCreatingRepetitionsFor(prev => new Set(prev).add(experimentId)) setError(null) - const [allUsers, allPhases] = await Promise.all([ - userManagement.getAllUsers(), - experimentPhaseManagement.getAllExperimentPhases() - ]) + // Create all repetitions for this experiment + const newRepetitions = await repetitionManagement.createAllRepetitions(experimentId) - // Filter conductors - const conductorsOnly = allUsers.filter(u => u.roles.includes('conductor')) - setConductors(conductorsOnly) - - // For each conductor, check if they have any availability in the future - const conductorIds = conductorsOnly.map(c => c.id) - setConductorIdsWithFutureAvailability(new Set(conductorIds)) - - setPhases(allPhases) - } catch (e: any) { - setError(e?.message || 'Failed to load scheduling data') - } finally { - setLoading(false) - } - } - load() - }, []) - - const toggleConductor = (id: string) => { - setSelectedConductorIds(prev => { - const next = new Set(prev) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }) - } - - const toggleAllConductors = () => { - const availableConductorIds = conductors - .filter(c => conductorIdsWithFutureAvailability.has(c.id)) - .map(c => c.id) - - setSelectedConductorIds(prev => { - // If all available conductors are selected, deselect all - const allSelected = availableConductorIds.every(id => prev.has(id)) - if (allSelected) { - return new Set() - } else { - // Select all available conductors - return new Set(availableConductorIds) - } - }) - } - - // Fetch availability for selected conductors and build calendar events - useEffect(() => { - const loadSelectedAvailability = async () => { - try { - const selectedIds = Array.from(selectedConductorIds) - if (selectedIds.length === 0) { - setAvailabilityEvents([]) - return - } - - // Assign consistent colors per conductor based on their position in the full conductors array - // This ensures the same conductor always gets the same color, matching the checkbox list - const newColorMap: Record = {} - conductors.forEach((conductor, index) => { - if (selectedIds.includes(conductor.id)) { - newColorMap[conductor.id] = colorPalette[index % colorPalette.length] - } - }) - setConductorColorMap(newColorMap) - - // Fetch availability for selected conductors in a single query - const { data, error } = await supabase - .from('conductor_availability') - .select('*') - .in('user_id', selectedIds) - .eq('status', 'active') - .gt('available_to', new Date().toISOString()) - .order('available_from', { ascending: true }) - - if (error) throw error - - // Map user_id -> display name - const idToName: Record = {} - conductors.forEach(c => { - const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email - idToName[c.id] = name - }) - - const events: CalendarEvent[] = (data || []).map((r: any) => { - 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) { - // 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 + // Update the repetitions state 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])) + [experimentId]: newRepetitions })) } catch (e: any) { - setError(e?.message || 'Failed to load experiments or repetitions') + setError(e?.message || 'Failed to create repetitions') + } finally { + setCreatingRepetitionsFor(prev => { + const next = new Set(prev) + next.delete(experimentId) + return next + }) } } - } - 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 + // Re-stagger all repetitions to prevent overlap + // 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) + + setScheduledRepetitions(prev => { + const newScheduled = { ...prev } + + // 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 = staggerIndex * 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 + } + staggerIndex++ + } + } + } }) - // Clear all related state when unchecked - setRepetitionsWithTimes(prev => { - const next = new Set(prev) + + return newScheduled + }) + }, [lockedSchedules, repetitionsByExperiment, experimentsByPhase, soakingByExperiment, airdryingByExperiment]) + + // 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 = useCallback((repId: string, phase: 'soaking' | 'airdrying' | 'cracking', newTime: Date) => { + console.log('updatePhaseTiming called:', repId, phase, newTime) + setScheduledRepetitions(prev => { + const current = prev[repId] + if (!current) { + console.log('No current repetition found for:', repId) + return prev + } + + const experimentId = current.experimentId + const soaking = soakingByExperiment[experimentId] + const airdrying = airdryingByExperiment[experimentId] + + if (!soaking || !airdrying) { + console.log('Missing soaking or airdrying data for experiment:', experimentId) + 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 + } + console.log('Updated repetition times:', newScheduled[repId]) + } 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 + } + console.log('Updated repetition times:', newScheduled[repId]) + } 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 + } + console.log('Updated repetition times:', newScheduled[repId]) + } + + return newScheduled + }) + }, [soakingByExperiment, airdryingByExperiment]) + + // 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) { + const isLocked = lockedSchedules.has(scheduled.repetitionId) + const lockIcon = isLocked ? '🔒' : '🔓' + + // Soaking marker + events.push({ + id: `${scheduled.repetitionId}-soaking`, + 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' + }) + + // Airdrying marker + if (scheduled.airdryingStart) { + events.push({ + id: `${scheduled.repetitionId}-airdrying`, + 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' + }) + } + + // Cracking marker + if (scheduled.crackingStart) { + events.push({ + id: `${scheduled.repetitionId}-cracking`, + 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' + }) + } + } + }) + + return events + }, [scheduledRepetitions, experimentsByPhase, repetitionsByExperiment, lockedSchedules]) + + // Memoize the calendar events + const calendarEvents = useMemo(() => { + return 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) - return next + } 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() }) - setLockedSchedules(prev => { - const next = new Set(prev) - next.delete(repId) - return next + + // 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 }) - // 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 - // Note: reStaggerRepetitions will automatically skip locked repetitions - 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 - // 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) - - setScheduledRepetitions(prev => { - const newScheduled = { ...prev } - - // 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 = staggerIndex * 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 - } - staggerIndex++ - } - } - } - }) - - return newScheduled - }) - }, [lockedSchedules, repetitionsByExperiment, experimentsByPhase, soakingByExperiment, airdryingByExperiment]) - - // 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] + // 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) - 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 + return () => clearTimeout(timeoutId) + } + }, [scheduledRepetitions, restoreScrollPosition]) - 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)) + // Transform data for horizontal timeline calendar + const horizontalCalendarData = useMemo(() => { + // Calculate date range based on zoom level and current start date + const startDate = new Date(calendarStartDate) + startDate.setHours(0, 0, 0, 0) + const endDate = new Date(startDate) + endDate.setDate(endDate.getDate() + (calendarZoom - 1)) - setScheduledRepetitions(prev => ({ - ...prev, - [repId]: { + // Transform conductor availabilities + const conductorAvailabilities = conductors + .filter(c => selectedConductorIds.has(c.id)) + .map((conductor, index) => { + const conductorName = [conductor.first_name, conductor.last_name].filter(Boolean).join(' ') || conductor.email + const color = conductorColorMap[conductor.id] || colorPalette[index % colorPalette.length] + + const availability = availabilityEvents + .filter(event => event.resource === conductor.id) + .map(event => ({ + start: new Date(event.start), + end: new Date(event.end) + })) + + return { + conductorId: conductor.id, + conductorName, + color, + availability + } + }) + + // Transform phase markers + const phaseMarkers: Array<{ + id: string + repetitionId: string + experimentId: string + phase: 'soaking' | 'airdrying' | 'cracking' + startTime: Date + assignedConductors: string[] + locked: boolean + }> = [] + + Object.values(scheduledRepetitions).forEach(scheduled => { + const repId = scheduled.repetitionId + const markerIdPrefix = repId + + if (scheduled.soakingStart) { + phaseMarkers.push({ + id: `${markerIdPrefix}-soaking`, 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 + experimentId: scheduled.experimentId, + phase: 'soaking', + startTime: scheduled.soakingStart, + assignedConductors: conductorAssignments[`${markerIdPrefix}-soaking`] || [], + locked: lockedSchedules.has(repId) + }) } - } 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) { - const isLocked = lockedSchedules.has(scheduled.repetitionId) - const lockIcon = isLocked ? '🔒' : '🔓' - - // Soaking marker - events.push({ - id: `${scheduled.repetitionId}-soaking`, - 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' - }) - - // Airdrying marker if (scheduled.airdryingStart) { - events.push({ - id: `${scheduled.repetitionId}-airdrying`, - 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' + phaseMarkers.push({ + id: `${markerIdPrefix}-airdrying`, + repetitionId: repId, + experimentId: scheduled.experimentId, + phase: 'airdrying', + startTime: scheduled.airdryingStart, + assignedConductors: conductorAssignments[`${markerIdPrefix}-airdrying`] || [], + locked: lockedSchedules.has(repId) }) } - // Cracking marker if (scheduled.crackingStart) { - events.push({ - id: `${scheduled.repetitionId}-cracking`, - 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' + phaseMarkers.push({ + id: `${markerIdPrefix}-cracking`, + repetitionId: repId, + experimentId: scheduled.experimentId, + phase: 'cracking', + startTime: scheduled.crackingStart, + assignedConductors: conductorAssignments[`${markerIdPrefix}-cracking`] || [], + locked: lockedSchedules.has(repId) }) } - } - }) - - return events - }, [scheduledRepetitions, experimentsByPhase, repetitionsByExperiment, lockedSchedules]) - - // Memoize the calendar events - const calendarEvents = useMemo(() => { - return 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 - } + startDate, + endDate, + conductorAvailabilities, + phaseMarkers } - } + }, [selectedConductorIds, conductors, conductorColorMap, colorPalette, availabilityEvents, scheduledRepetitions, conductorAssignments, lockedSchedules, calendarStartDate, calendarZoom]) - // Default styling for other events - return {} - }, [lockedSchedules]) + // Handlers for horizontal calendar + const handleHorizontalMarkerDrag = useCallback((markerId: string, newTime: Date) => { + console.log('handleHorizontalMarkerDrag called:', markerId, newTime) + // Marker ID format: ${repId}-${phase} where repId is a UUID with hyphens + // Split by '-' and take the last segment as phase, rest as repId + const parts = markerId.split('-') + const phase = parts[parts.length - 1] as 'soaking' | 'airdrying' | 'cracking' + const repId = parts.slice(0, -1).join('-') + console.log('Updating phase timing:', repId, phase, newTime) + updatePhaseTiming(repId, phase, newTime) + setRepetitionsWithTimes(prev => new Set(prev).add(repId)) + }, [updatePhaseTiming]) - 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 => ({ + const handleHorizontalMarkerAssignConductors = useCallback((markerId: string, conductorIds: string[]) => { + setConductorAssignments(prev => ({ ...prev, - [experimentId]: prev[experimentId]?.map(r => - r.id === repId - ? { ...r, scheduled_date: soakingStart.toISOString() } - : r - ) || [] + [markerId]: conductorIds })) + }, []) - } catch (error: any) { - setError(error?.message || 'Failed to schedule repetition') - } finally { - setSchedulingRepetitions(prev => { + const handleHorizontalMarkerLockToggle = useCallback((markerId: string) => { + // Marker ID format: ${repId}-${phase} where repId is a UUID with hyphens + // Split by '-' and take all but the last segment as repId + const parts = markerId.split('-') + const repId = parts.slice(0, -1).join('-') + setLockedSchedules(prev => { const next = new Set(prev) - next.delete(repId) + if (next.has(repId)) { + next.delete(repId) + } else { + next.add(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. -

-
+ 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 +
+
+ {error && ( +
{error}
+ )} + {loading ? ( +
+
+ + +
-
- {conductors.length === 0 ? ( -
No conductors found.
- ) : ( -
+

Loading…

+

Fetching conductors, phases, and experiments.

+
+ ) : ( + <> + {/* Horizontal Timeline Calendar - First */} +
+
+

Selected Conductors' Availability & Experiment Scheduling

+ + {/* Navigation and Zoom Controls */} +
+ {/* Previous Day Button */} + + {/* Date Display */} +
+ {moment(horizontalCalendarData.startDate).format('MMM D')} - {moment(horizontalCalendarData.endDate).format('MMM D, YYYY')} +
+ + {/* Next Day Button */} + - {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 ( - - ) - })} -
-
- )} + {/* Zoom Out Button (show more days) */} + + + {/* Zoom Level Display */} +
+ {calendarZoom} {calendarZoom === 1 ? 'day' : 'days'} +
+ + {/* Zoom In Button (show fewer days) */} + + + {/* Today Button */} +
- )} +
+
+ +
-
- {/* 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 && ( + {/* Conductors and Experiment Phases Dropdowns - Second */} +
+ {/* Left: Conductors with future availability */} +
+
+

Conductors

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

-
-
- - { - // 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 - } - }} - 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} - /> - -
- + + )}
-
- ) -} + ) + } // Availability Calendar Component function AvailabilityCalendar({ user }: { user: User }) { @@ -1664,13 +1546,13 @@ function AvailabilityCalendar({ user }: { user: User }) { )} {/* Calendar */} -
+
void +} + +export function AvailabilityCalendar({ user, onBack }: AvailabilityCalendarProps) { + 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 && ( + + )} + + {/* Calendar */} +
+ +
+
+ ) +} + diff --git a/scheduling-remote/src/components/scheduling/Scheduling.tsx b/scheduling-remote/src/components/scheduling/Scheduling.tsx new file mode 100644 index 0000000..55cbbe5 --- /dev/null +++ b/scheduling-remote/src/components/scheduling/Scheduling.tsx @@ -0,0 +1,120 @@ +import type { SchedulingProps, SchedulingView } from './types' +import { ViewSchedule } from './views/ViewSchedule' +import { IndicateAvailability } from './views/IndicateAvailability' +import { SchedulingCard } from './ui/SchedulingCard' + +// Import the original ScheduleExperiment for now +// TODO: Further refactor ScheduleExperiment into smaller components +import { ScheduleExperiment } from '../Scheduling' + +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 */} + + + + } + status={{ label: 'Available', color: 'green' }} + footer={{ left: 'All experiments', right: 'View Schedule' }} + onClick={() => handleCardClick('view-schedule')} + /> + + {/* Indicate Availability Card */} + + + + } + status={{ label: 'Active', color: 'blue' }} + footer={{ left: 'Personal settings', right: 'Set Availability' }} + onClick={() => handleCardClick('indicate-availability')} + /> + + {/* Schedule Experiment Card */} + + + + } + status={{ label: 'Planning', color: 'yellow' }} + footer={{ left: 'Experiment planning', right: 'Schedule Now' }} + onClick={() => handleCardClick('schedule-experiment')} + /> +
+
+ ) +} + diff --git a/scheduling-remote/src/components/scheduling/hooks/useConductors.ts b/scheduling-remote/src/components/scheduling/hooks/useConductors.ts new file mode 100644 index 0000000..bad29f9 --- /dev/null +++ b/scheduling-remote/src/components/scheduling/hooks/useConductors.ts @@ -0,0 +1,123 @@ +import { useState, useEffect } from 'react' +import type { User, CalendarEvent } from '../types' +import { userManagement, supabase } from '../../../services/supabase' + +const colorPalette = ['#2563eb', '#059669', '#d97706', '#7c3aed', '#dc2626', '#0ea5e9', '#16a34a', '#f59e0b', '#9333ea', '#ef4444'] + +export function useConductors() { + const [conductors, setConductors] = useState([]) + const [conductorIdsWithFutureAvailability, setConductorIdsWithFutureAvailability] = useState>(new Set()) + const [selectedConductorIds, setSelectedConductorIds] = useState>(new Set()) + const [availabilityEvents, setAvailabilityEvents] = useState([]) + const [conductorColorMap, setConductorColorMap] = useState>({}) + + useEffect(() => { + const loadConductors = async () => { + try { + const allUsers = await userManagement.getAllUsers() + const conductorsOnly = allUsers.filter(u => u.roles.includes('conductor')) + setConductors(conductorsOnly) + const conductorIds = conductorsOnly.map(c => c.id) + setConductorIdsWithFutureAvailability(new Set(conductorIds)) + } catch (e) { + console.error('Failed to load conductors', e) + } + } + loadConductors() + }, []) + + // 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 + 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) => { + 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 + } + }) + + 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 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 => { + const allSelected = availableConductorIds.every(id => prev.has(id)) + if (allSelected) { + return new Set() + } else { + return new Set(availableConductorIds) + } + }) + } + + return { + conductors, + conductorIdsWithFutureAvailability, + selectedConductorIds, + availabilityEvents, + conductorColorMap, + colorPalette, + toggleConductor, + toggleAllConductors + } +} + diff --git a/scheduling-remote/src/components/scheduling/types.ts b/scheduling-remote/src/components/scheduling/types.ts new file mode 100644 index 0000000..6d8eb67 --- /dev/null +++ b/scheduling-remote/src/components/scheduling/types.ts @@ -0,0 +1,29 @@ +import type { User, ExperimentPhase, Experiment, ExperimentRepetition, Soaking, Airdrying } from '../../services/supabase' + +// Type definitions for calendar events +export interface CalendarEvent { + id: number | string + title: string + start: Date + end: Date + resource?: string +} + +export interface SchedulingProps { + user: User + currentRoute: string +} + +export type SchedulingView = 'main' | 'view-schedule' | 'indicate-availability' | 'schedule-experiment' + +export interface ScheduledRepetition { + repetitionId: string + experimentId: string + soakingStart: Date | null + airdryingStart: Date | null + crackingStart: Date | null +} + +// Re-export types for convenience +export type { User, ExperimentPhase, Experiment, ExperimentRepetition, Soaking, Airdrying } + diff --git a/scheduling-remote/src/components/scheduling/ui/BackButton.tsx b/scheduling-remote/src/components/scheduling/ui/BackButton.tsx new file mode 100644 index 0000000..120340a --- /dev/null +++ b/scheduling-remote/src/components/scheduling/ui/BackButton.tsx @@ -0,0 +1,19 @@ +interface BackButtonProps { + onClick: () => void + label?: string +} + +export function BackButton({ onClick, label = 'Back to Scheduling' }: BackButtonProps) { + return ( + + ) +} + diff --git a/scheduling-remote/src/components/scheduling/ui/DropdownCurtain.tsx b/scheduling-remote/src/components/scheduling/ui/DropdownCurtain.tsx new file mode 100644 index 0000000..faa019b --- /dev/null +++ b/scheduling-remote/src/components/scheduling/ui/DropdownCurtain.tsx @@ -0,0 +1,35 @@ +import { ReactNode } from 'react' + +interface DropdownCurtainProps { + title: string + expanded: boolean + onToggle: () => void + children: ReactNode + headerAction?: ReactNode + className?: string +} + +export function DropdownCurtain({ title, expanded, onToggle, children, headerAction, className = '' }: DropdownCurtainProps) { + return ( +
+ + {expanded && ( +
+ {children} +
+ )} +
+ ) +} + diff --git a/scheduling-remote/src/components/scheduling/ui/SchedulingCard.tsx b/scheduling-remote/src/components/scheduling/ui/SchedulingCard.tsx new file mode 100644 index 0000000..396c4c5 --- /dev/null +++ b/scheduling-remote/src/components/scheduling/ui/SchedulingCard.tsx @@ -0,0 +1,65 @@ +import type { SchedulingView } from '../types' + +interface SchedulingCardProps { + title: string + description: string + icon: React.ReactNode + status: { + label: string + color: 'green' | 'blue' | 'yellow' + } + footer: { + left: string + right: string + } + onClick: () => void +} + +export function SchedulingCard({ title, description, icon, status, footer, onClick }: SchedulingCardProps) { + const statusColors = { + green: 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-400', + blue: 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-400', + yellow: 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-400', + } + + return ( +
+
+
+
+
+ {icon} +
+
+
+ + {status.label} + +
+
+ +

+ {title} +

+ +

+ {description} +

+ +
+ {footer.left} +
+ {footer.right} + + + +
+
+
+
+ ) +} + diff --git a/scheduling-remote/src/components/scheduling/ui/TimeSlotModal.tsx b/scheduling-remote/src/components/scheduling/ui/TimeSlotModal.tsx new file mode 100644 index 0000000..818253f --- /dev/null +++ b/scheduling-remote/src/components/scheduling/ui/TimeSlotModal.tsx @@ -0,0 +1,123 @@ +import moment from 'moment' +import type { CalendarEvent } from '../types' + +interface TimeSlotModalProps { + selectedDate: Date + newTimeSlot: { + startTime: string + endTime: string + } + existingEvents: CalendarEvent[] + onTimeSlotChange: (timeSlot: { startTime: string; endTime: string }) => void + onAdd: () => void + onCancel: () => void +} + +export function TimeSlotModal({ + selectedDate, + newTimeSlot, + existingEvents, + onTimeSlotChange, + onAdd, + onCancel +}: TimeSlotModalProps) { + const getEventsForDate = (date: Date) => { + return existingEvents.filter(event => { + const eventDate = new Date(event.start) + return eventDate.toDateString() === date.toDateString() + }) + } + + return ( +
+
+
e.stopPropagation()}> + {/* Close Button */} + + +

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

+ +
+
+ + onTimeSlotChange({ ...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" + /> +
+ +
+ + onTimeSlotChange({ ...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}) +
+ ))} +
+
+ )} + +
+ + +
+
+
+ ) +} + 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. +

+
+
+
+ ) +} +