From d598281164b84a0f9941fc1af524fcda4b1c62b7 Mon Sep 17 00:00:00 2001 From: Alireza Vaezi Date: Mon, 28 Jul 2025 16:30:56 -0400 Subject: [PATCH] feat: Implement Vision System API Client with comprehensive endpoints and utility functions - Added VisionApiClient class to interact with the vision system API. - Defined interfaces for system status, machine status, camera status, recordings, and storage stats. - Implemented methods for health checks, system status retrieval, camera control, and storage management. - Introduced utility functions for formatting bytes, durations, and uptime. test: Create manual verification script for Vision API functionality - Added a test script to verify utility functions and API endpoints. - Included tests for health check, system status, cameras, machines, and storage stats. feat: Create experiment repetitions system migration - Added experiment_repetitions table to manage experiment repetitions with scheduling. - Implemented triggers and functions for validation and timestamp management. - Established row-level security policies for user access control. feat: Introduce phase-specific draft management system migration - Created experiment_phase_drafts and experiment_phase_data tables for managing phase-specific drafts and measurements. - Added pecan_diameter_measurements table for individual diameter measurements. - Implemented row-level security policies for user access control. fix: Adjust draft constraints to allow multiple drafts while preventing multiple submitted drafts - Modified constraints on experiment_phase_drafts to allow multiple drafts in 'draft' or 'withdrawn' status. - Ensured only one 'submitted' draft per user per phase per repetition. --- VISION_SYSTEM_README.md | 137 ++++ api-endpoints.http | 434 +++++++++++ phase_2_experimental_run_sheet.csv | 61 ++ src/components/DashboardHome.tsx | 2 +- src/components/DashboardLayout.tsx | 3 + src/components/DataEntry.tsx | 319 ++++++-- src/components/DataEntryInterface.tsx | 100 +-- src/components/DraftManager.tsx | 29 +- src/components/Experiments.tsx | 239 +++--- src/components/PhaseDataEntry.tsx | 205 +++-- src/components/PhaseDraftManager.tsx | 276 +++++++ src/components/PhaseSelector.tsx | 22 +- .../RepetitionDataEntryInterface.tsx | 115 +++ src/components/RepetitionLockManager.tsx | 124 +++ src/components/RepetitionPhaseSelector.tsx | 223 ++++++ src/components/RepetitionScheduleModal.tsx | 208 +++++ src/components/Sidebar.tsx | 11 +- src/components/TopNavbar.tsx | 2 + src/components/VisionSystem.tsx | 735 ++++++++++++++++++ src/lib/supabase.ts | 359 +++++++-- src/lib/visionApi.ts | 336 ++++++++ src/test/visionApi.test.ts | 51 ++ ...723000001_experiment_data_entry_system.sql | 263 ------- ...24000001_experiment_repetitions_system.sql | 135 ++++ ...725000001_experiment_data_entry_system.sql | 332 ++++++++ .../20250725000003_fix_draft_constraints.sql | 17 + supabase/seed.sql | 164 ++-- 27 files changed, 4219 insertions(+), 683 deletions(-) create mode 100644 VISION_SYSTEM_README.md create mode 100644 api-endpoints.http create mode 100644 phase_2_experimental_run_sheet.csv create mode 100644 src/components/PhaseDraftManager.tsx create mode 100644 src/components/RepetitionDataEntryInterface.tsx create mode 100644 src/components/RepetitionLockManager.tsx create mode 100644 src/components/RepetitionPhaseSelector.tsx create mode 100644 src/components/RepetitionScheduleModal.tsx create mode 100644 src/components/VisionSystem.tsx create mode 100644 src/lib/visionApi.ts create mode 100644 src/test/visionApi.test.ts delete mode 100644 supabase/migrations/20250723000001_experiment_data_entry_system.sql create mode 100644 supabase/migrations/20250724000001_experiment_repetitions_system.sql create mode 100644 supabase/migrations/20250725000001_experiment_data_entry_system.sql create mode 100644 supabase/migrations/20250725000003_fix_draft_constraints.sql diff --git a/VISION_SYSTEM_README.md b/VISION_SYSTEM_README.md new file mode 100644 index 0000000..fe0cfe0 --- /dev/null +++ b/VISION_SYSTEM_README.md @@ -0,0 +1,137 @@ +# Vision System Dashboard + +This document describes the Vision System dashboard that has been added to the Pecan Experiments application. + +## Overview + +The Vision System dashboard provides real-time monitoring and control of the USDA Vision Camera System. It displays information about cameras, machines, storage, and recording sessions. + +## Features + +### System Overview +- **System Status**: Shows if the vision system is online/offline with uptime information +- **MQTT Connection**: Displays MQTT connectivity status and last message timestamp +- **Active Recordings**: Shows current number of active recordings and total recordings +- **Camera/Machine Count**: Quick overview of connected devices + +### Camera Monitoring +- **Real-time Status**: Shows connection status for each camera (camera1, camera2) +- **Recording State**: Indicates if cameras are currently recording +- **Device Information**: Displays friendly names and serial numbers +- **Error Reporting**: Shows any camera errors or issues +- **Current Recording Files**: Shows active recording filenames + +### Machine Status +- **Machine States**: Monitors vibratory conveyor, blower separator, and other machines +- **MQTT Topics**: Shows the MQTT topics for each machine +- **Last Updated**: Timestamps for when each machine status was last updated +- **State Colors**: Visual indicators for machine states (on/off/running/stopped) + +### Storage Management +- **Disk Usage**: Visual progress bar showing total disk usage +- **File Statistics**: Total files, total size, and free space +- **Per-Camera Breakdown**: Storage usage statistics for each camera +- **Storage Path**: Shows the base storage directory + +### Recording Sessions +- **Recent Recordings Table**: Shows the latest recording sessions +- **Recording Details**: Filename, camera, status, duration, file size, start time +- **Status Indicators**: Visual status badges for completed/active/failed recordings + +## API Integration + +The dashboard connects to the Vision System API running on `http://localhost:8000` and provides: + +### Endpoints Used +- `GET /system/status` - System overview and status +- `GET /cameras` - Camera status and information +- `GET /machines` - Machine status and MQTT data +- `GET /storage/stats` - Storage usage statistics +- `GET /recordings` - Recording session information + +### Auto-Refresh +- The dashboard automatically refreshes data every 5 seconds +- Manual refresh button available for immediate updates +- Loading indicators show when data is being fetched + +## Access Control + +The Vision System dashboard is accessible to all authenticated users regardless of role: +- **Admin**: Full access to all features +- **Conductor**: Full access to all features +- **Analyst**: Full access to all features +- **Data Recorder**: Full access to all features + +## Error Handling + +The dashboard includes comprehensive error handling: +- **Connection Errors**: Shows user-friendly messages when the vision system is unavailable +- **API Errors**: Displays specific error messages from the vision system API +- **Retry Functionality**: "Try Again" button to retry failed requests +- **Graceful Degradation**: Shows partial data if some API calls fail + +## Technical Implementation + +### Files Added/Modified +- `src/lib/visionApi.ts` - API client for vision system integration +- `src/components/VisionSystem.tsx` - Main dashboard component +- `src/components/DashboardLayout.tsx` - Added routing for vision system +- `src/components/Sidebar.tsx` - Added menu item for vision system +- `src/components/TopNavbar.tsx` - Added page title for vision system + +### Dependencies +- Uses existing React/TypeScript setup +- Leverages Tailwind CSS for styling +- No additional dependencies required + +### API Client Features +- TypeScript interfaces for all API responses +- Comprehensive error handling +- Utility functions for formatting (bytes, duration, uptime) +- Singleton pattern for API client instance + +## Usage + +1. **Navigate to Vision System**: Click "Vision System" in the sidebar menu +2. **Monitor Status**: View real-time system, camera, and machine status +3. **Check Storage**: Monitor disk usage and file statistics +4. **Review Recordings**: See recent recording sessions and their details +5. **Refresh Data**: Use the refresh button or wait for auto-refresh + +## Troubleshooting + +### Common Issues + +1. **"Failed to fetch vision system data"** + - Ensure the vision system API is running on localhost:8000 + - Check network connectivity + - Verify the vision system service is started + +2. **Empty Dashboard** + - Vision system may not have any cameras or machines configured + - Check vision system configuration + - Verify MQTT connectivity + +3. **Outdated Information** + - Data refreshes every 5 seconds automatically + - Use the manual refresh button for immediate updates + - Check if vision system is responding to API calls + +### API Base URL Configuration + +The API base URL is configured in `src/lib/visionApi.ts`: +```typescript +const VISION_API_BASE_URL = 'http://localhost:8000' +``` + +To change the API endpoint, modify this constant and rebuild the application. + +## Future Enhancements + +Potential improvements for the vision system dashboard: +- **Camera Controls**: Add start/stop recording buttons +- **Camera Recovery**: Add diagnostic and recovery action buttons +- **File Management**: Add file browsing and cleanup functionality +- **Real-time Streaming**: Add live camera feed display +- **Alerts**: Add notifications for system issues +- **Historical Data**: Add charts and trends for system metrics diff --git a/api-endpoints.http b/api-endpoints.http new file mode 100644 index 0000000..e5ca139 --- /dev/null +++ b/api-endpoints.http @@ -0,0 +1,434 @@ +############################################################################### +# USDA Vision Camera System - Complete API Endpoints Documentation +# Base URL: http://localhost:8000 +############################################################################### + +############################################################################### +# SYSTEM ENDPOINTS +############################################################################### + +### Root endpoint - API information +GET http://localhost:8000/ +# Response: SuccessResponse +# { +# "success": true, +# "message": "USDA Vision Camera System API", +# "data": null, +# "timestamp": "2025-07-28T12:00:00" +# } + +### + +### Health check +GET http://localhost:8000/health +# Response: Simple health status +# { +# "status": "healthy", +# "timestamp": "2025-07-28T12:00:00" +# } + +### + +### Get system status +GET http://localhost:8000/system/status +# Response: SystemStatusResponse +# { +# "system_started": true, +# "mqtt_connected": true, +# "last_mqtt_message": "2025-07-28T12:00:00", +# "machines": { +# "vibratory_conveyor": { +# "name": "vibratory_conveyor", +# "state": "off", +# "last_updated": "2025-07-28T12:00:00" +# } +# }, +# "cameras": { +# "camera1": { +# "name": "camera1", +# "status": "connected", +# "is_recording": false +# } +# }, +# "active_recordings": 0, +# "total_recordings": 5, +# "uptime_seconds": 3600.5 +# } + +############################################################################### +# MACHINE ENDPOINTS +############################################################################### + +### Get all machines status +GET http://localhost:8000/machines +# Response: Dict[str, MachineStatusResponse] +# { +# "vibratory_conveyor": { +# "name": "vibratory_conveyor", +# "state": "off", +# "last_updated": "2025-07-28T12:00:00", +# "last_message": "off", +# "mqtt_topic": "vision/vibratory_conveyor/state" +# }, +# "blower_separator": { +# "name": "blower_separator", +# "state": "on", +# "last_updated": "2025-07-28T12:00:00", +# "last_message": "on", +# "mqtt_topic": "vision/blower_separator/state" +# } +# } + +############################################################################### +# MQTT ENDPOINTS +############################################################################### + +### Get MQTT status and statistics +GET http://localhost:8000/mqtt/status +# Response: MQTTStatusResponse +# { +# "connected": true, +# "broker_host": "192.168.1.110", +# "broker_port": 1883, +# "subscribed_topics": [ +# "vision/vibratory_conveyor/state", +# "vision/blower_separator/state" +# ], +# "last_message_time": "2025-07-28T12:00:00", +# "message_count": 42, +# "error_count": 0, +# "uptime_seconds": 3600.5 +# } + +### Get recent MQTT events history +GET http://localhost:8000/mqtt/events +# Optional query parameter: limit (default: 5, max: 50) +# Response: MQTTEventsHistoryResponse +# { +# "events": [ +# { +# "machine_name": "vibratory_conveyor", +# "topic": "vision/vibratory_conveyor/state", +# "payload": "on", +# "normalized_state": "on", +# "timestamp": "2025-07-28T15:30:45.123456", +# "message_number": 15 +# }, +# { +# "machine_name": "blower_separator", +# "topic": "vision/blower_separator/state", +# "payload": "off", +# "normalized_state": "off", +# "timestamp": "2025-07-28T15:29:12.654321", +# "message_number": 14 +# } +# ], +# "total_events": 15, +# "last_updated": "2025-07-28T15:30:45.123456" +# } + +### Get recent MQTT events with custom limit +GET http://localhost:8000/mqtt/events?limit=10 + +############################################################################### +# CAMERA ENDPOINTS +############################################################################### + +### Get all cameras status +GET http://localhost:8000/cameras +# Response: Dict[str, CameraStatusResponse] +# { +# "camera1": { +# "name": "camera1", +# "status": "connected", +# "is_recording": false, +# "last_checked": "2025-07-28T12:00:00", +# "last_error": null, +# "device_info": { +# "friendly_name": "MindVision Camera", +# "serial_number": "ABC123" +# }, +# "current_recording_file": null, +# "recording_start_time": null +# } +# } + +### + +### Get specific camera status +GET http://localhost:8000/cameras/camera1/status +### Get specific camera status +GET http://localhost:8000/cameras/camera2/status +# Response: CameraStatusResponse (same as above for single camera) + +############################################################################### +# RECORDING CONTROL ENDPOINTS +############################################################################### + +### Start recording (with all optional parameters) +POST http://localhost:8000/cameras/camera1/start-recording +Content-Type: application/json + +{ + "filename": "test_recording.avi", + "exposure_ms": 1.5, + "gain": 3.0, + "fps": 0 +} +### +# Request Parameters (all optional): +# - filename: string - Custom filename (datetime prefix auto-added) +# - exposure_ms: float - Exposure time in milliseconds +# - gain: float - Camera gain value +# - fps: float - Target frames per second (0 = maximum speed, omit = use config default) +# +# Response: StartRecordingResponse +# { +# "success": true, +# "message": "Recording started for camera1", +# "filename": "20250728_120000_test_recording.avi" +# } + +### + +### Start recording (minimal - only filename) +POST http://localhost:8000/cameras/camera1/start-recording +Content-Type: application/json + +{ + "filename": "simple_test.avi" +} + +### + +### Start recording (only camera settings) +POST http://localhost:8000/cameras/camera1/start-recording +Content-Type: application/json + +{ + "exposure_ms": 2.0, + "gain": 4.0, + "fps": 0 +} + +### + +### Start recording (empty body - all defaults) +POST http://localhost:8000/cameras/camera1/start-recording +Content-Type: application/json + +{} + +### + +### Stop recording +POST http://localhost:8000/cameras/camera1/stop-recording +### Stop recording +POST http://localhost:8000/cameras/camera2/stop-recording +# No request body required +# Response: StopRecordingResponse +# { +# "success": true, +# "message": "Recording stopped for camera1", +# "duration_seconds": 45.2 +# } + +############################################################################### +# CAMERA RECOVERY & DIAGNOSTICS ENDPOINTS +############################################################################### + +### Test camera connection +POST http://localhost:8000/cameras/camera1/test-connection +POST http://localhost:8000/cameras/camera2/test-connection +# No request body required +# Response: CameraTestResponse +# { +# "success": true, +# "message": "Camera camera1 connection test passed", +# "camera_name": "camera1", +# "timestamp": "2025-07-28T12:00:00" +# } + +### + +### Reconnect camera (soft recovery) +POST http://localhost:8000/cameras/camera1/reconnect +POST http://localhost:8000/cameras/camera2/reconnect +# No request body required +# Response: CameraRecoveryResponse +# { +# "success": true, +# "message": "Camera camera1 reconnected successfully", +# "camera_name": "camera1", +# "operation": "reconnect", +# "timestamp": "2025-07-28T12:00:00" +# } + +### + +### Restart camera grab process +POST http://localhost:8000/cameras/camera1/restart-grab +POST http://localhost:8000/cameras/camera2/restart-grab +# Response: CameraRecoveryResponse (same structure as reconnect) + +### + +### Reset camera timestamp +POST http://localhost:8000/cameras/camera1/reset-timestamp +POST http://localhost:8000/cameras/camera2/reset-timestamp +# Response: CameraRecoveryResponse (same structure as reconnect) + +### + +### Full camera reset (hard recovery) +POST http://localhost:8000/cameras/camera1/full-reset +### Full camera reset (hard recovery) + +POST http://localhost:8000/cameras/camera2/full-reset +# Response: CameraRecoveryResponse (same structure as reconnect) + +### + +### Reinitialize failed camera +POST http://localhost:8000/cameras/camera1/reinitialize +### Reinitialize failed camera + +POST http://localhost:8000/cameras/camera2/reinitialize +# Response: CameraRecoveryResponse (same structure as reconnect) + +############################################################################### +# RECORDING SESSIONS ENDPOINT +############################################################################### + +### Get all recording sessions +GET http://localhost:8000/recordings +# Response: Dict[str, RecordingInfoResponse] +# { +# "rec_001": { +# "camera_name": "camera1", +# "filename": "20250728_120000_test.avi", +# "start_time": "2025-07-28T12:00:00", +# "state": "completed", +# "end_time": "2025-07-28T12:05:00", +# "file_size_bytes": 1048576, +# "frame_count": 1500, +# "duration_seconds": 300.0, +# "error_message": null +# } +# } + +############################################################################### +# STORAGE ENDPOINTS +############################################################################### + +### Get storage statistics +GET http://localhost:8000/storage/stats +# Response: StorageStatsResponse +# { +# "base_path": "/storage", +# "total_files": 25, +# "total_size_bytes": 52428800, +# "cameras": { +# "camera1": { +# "file_count": 15, +# "total_size_bytes": 31457280 +# } +# }, +# "disk_usage": { +# "total": 1000000000, +# "used": 500000000, +# "free": 500000000 +# } +# } + +### + +### Get recording files list (with filters) +POST http://localhost:8000/storage/files +Content-Type: application/json + +{ + "camera_name": "camera1", + "start_date": "2025-07-25T00:00:00", + "end_date": "2025-07-28T23:59:59", + "limit": 50 +} +# Request Parameters (all optional): +# - camera_name: string - Filter by specific camera +# - start_date: string (ISO format) - Filter files from this date +# - end_date: string (ISO format) - Filter files until this date +# - limit: integer (max 1000, default 100) - Maximum number of files to return +# +# Response: FileListResponse +# { +# "files": [ +# { +# "filename": "20250728_120000_test.avi", +# "camera_name": "camera1", +# "file_size_bytes": 1048576, +# "created_date": "2025-07-28T12:00:00", +# "duration_seconds": 300.0 +# } +# ], +# "total_count": 1 +# } + +### + +### Get all files (no camera filter) +POST http://localhost:8000/storage/files +Content-Type: application/json + +{ + "limit": 100 +} + +### + +### Cleanup old storage files +POST http://localhost:8000/storage/cleanup +Content-Type: application/json + +{ + "max_age_days": 7 +} +# Request Parameters: +# - max_age_days: integer (optional) - Remove files older than this many days +# If not provided, uses config default (30 days) +# +# Response: CleanupResponse +# { +# "files_removed": 5, +# "bytes_freed": 10485760, +# "errors": [] +# } + +############################################################################### +# ERROR RESPONSES +############################################################################### +# All endpoints may return ErrorResponse on failure: +# { +# "error": "Error description", +# "details": "Additional error details", +# "timestamp": "2025-07-28T12:00:00" +# } +# Common HTTP status codes: +# - 200: Success +# - 400: Bad Request (invalid parameters) +# - 404: Not Found (camera/resource not found) +# - 500: Internal Server Error +# - 503: Service Unavailable (camera manager not available) + +############################################################################### +# NOTES +############################################################################### +# 1. All timestamps are in ISO 8601 format +# 2. File sizes are in bytes +# 3. Camera names: "camera1", "camera2" +# 4. Machine names: "vibratory_conveyor", "blower_separator" +# 5. FPS behavior: +# - fps > 0: Capture at specified frame rate +# - fps = 0: Capture at MAXIMUM possible speed (no delay) +# - fps omitted: Uses camera config default +# 6. Filenames automatically get datetime prefix: YYYYMMDD_HHMMSS_filename.avi +# 7. Recovery endpoints should be used in order: test-connection → reconnect → restart-grab → full-reset → reinitialize diff --git a/phase_2_experimental_run_sheet.csv b/phase_2_experimental_run_sheet.csv new file mode 100644 index 0000000..6555955 --- /dev/null +++ b/phase_2_experimental_run_sheet.csv @@ -0,0 +1,61 @@ +experiment_id,run_number,soaking_duration_hr,air_drying_time_min,plate_contact_frequency_hz,throughput_rate_pecans_sec,crush_amount_in,entry_exit_height_diff_in,reps,rep +1,0,34,19,53,28,0.05,-0.09,3,1 +2,1,24,27,34,29,0.03,0.01,3,3 +3,12,28,59,37,23,0.06,-0.08,3,1 +4,15,16,60,30,24,0.07,0.02,3,1 +5,4,13,41,41,38,0.05,0.03,3,2 +6,18,18,49,38,35,0.07,-0.08,3,1 +7,11,24,59,42,25,0.07,-0.05,3,1 +8,16,20,59,41,14,0.07,0.04,3,1 +9,4,13,41,41,38,0.05,0.03,3,1 +10,19,11,25,56,34,0.06,-0.09,3,1 +11,15,16,60,30,24,0.07,0.02,3,2 +12,16,20,59,41,14,0.07,0.04,3,3 +13,10,26,60,44,12,0.08,-0.1,3,2 +14,1,24,27,34,29,0.03,0.01,3,1 +15,17,34,60,34,29,0.07,-0.09,3,2 +16,5,30,33,30,36,0.05,-0.04,3,3 +17,2,38,10,60,28,0.06,-0.1,3,3 +18,2,38,10,60,28,0.06,-0.1,3,1 +19,13,21,59,41,21,0.06,-0.09,3,2 +20,1,24,27,34,29,0.03,0.01,3,2 +21,14,22,59,45,17,0.07,-0.08,3,2 +22,6,10,22,37,30,0.06,0.02,3,2 +23,11,24,59,42,25,0.07,-0.05,3,2 +24,19,11,25,56,34,0.06,-0.09,3,2 +25,8,27,12,55,24,0.04,0.04,3,2 +26,18,18,49,38,35,0.07,-0.08,3,3 +27,5,30,33,30,36,0.05,-0.04,3,1 +28,9,32,26,47,26,0.07,0.03,3,1 +29,3,11,36,42,13,0.07,-0.07,3,1 +30,10,26,60,44,12,0.08,-0.1,3,1 +31,8,27,12,55,24,0.04,0.04,3,3 +32,5,30,33,30,36,0.05,-0.04,3,2 +33,8,27,12,55,24,0.04,0.04,3,1 +34,18,18,49,38,35,0.07,-0.08,3,2 +35,3,11,36,42,13,0.07,-0.07,3,3 +36,10,26,60,44,12,0.08,-0.1,3,3 +37,17,34,60,34,29,0.07,-0.09,3,3 +38,13,21,59,41,21,0.06,-0.09,3,3 +39,12,28,59,37,23,0.06,-0.08,3,2 +40,9,32,26,47,26,0.07,0.03,3,3 +41,14,22,59,45,17,0.07,-0.08,3,3 +42,0,34,19,53,28,0.05,-0.09,3,2 +43,7,15,30,35,32,0.05,-0.07,3,1 +44,0,34,19,53,28,0.05,-0.09,3,3 +45,15,16,60,30,24,0.07,0.02,3,3 +46,13,21,59,41,21,0.06,-0.09,3,1 +47,11,24,59,42,25,0.07,-0.05,3,3 +48,7,15,30,35,32,0.05,-0.07,3,3 +49,16,20,59,41,14,0.07,0.04,3,2 +50,3,11,36,42,13,0.07,-0.07,3,2 +51,7,15,30,35,32,0.05,-0.07,3,2 +52,6,10,22,37,30,0.06,0.02,3,1 +53,19,11,25,56,34,0.06,-0.09,3,3 +54,6,10,22,37,30,0.06,0.02,3,3 +55,2,38,10,60,28,0.06,-0.1,3,2 +56,14,22,59,45,17,0.07,-0.08,3,1 +57,4,13,41,41,38,0.05,0.03,3,3 +58,9,32,26,47,26,0.07,0.03,3,2 +59,17,34,60,34,29,0.07,-0.09,3,1 +60,12,28,59,37,23,0.06,-0.08,3,3 \ No newline at end of file diff --git a/src/components/DashboardHome.tsx b/src/components/DashboardHome.tsx index 553e163..21d033a 100644 --- a/src/components/DashboardHome.tsx +++ b/src/components/DashboardHome.tsx @@ -39,7 +39,7 @@ export function DashboardHome({ user }: DashboardHomeProps) {

Dashboard

-

Welcome to the RBAC system

+

Welcome to the Pecan Experiments Dashboard

{/* User Information Card */} diff --git a/src/components/DashboardLayout.tsx b/src/components/DashboardLayout.tsx index b80b53c..6e884ed 100644 --- a/src/components/DashboardLayout.tsx +++ b/src/components/DashboardLayout.tsx @@ -5,6 +5,7 @@ import { DashboardHome } from './DashboardHome' import { UserManagement } from './UserManagement' import { Experiments } from './Experiments' import { DataEntry } from './DataEntry' +import { VisionSystem } from './VisionSystem' import { userManagement, type User } from '../lib/supabase' interface DashboardLayoutProps { @@ -81,6 +82,8 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) { ) case 'data-entry': return + case 'vision-system': + return default: return } diff --git a/src/components/DataEntry.tsx b/src/components/DataEntry.tsx index dc5793d..aacc103 100644 --- a/src/components/DataEntry.tsx +++ b/src/components/DataEntry.tsx @@ -1,10 +1,11 @@ import { useState, useEffect } from 'react' -import { experimentManagement, userManagement, type Experiment, type User } from '../lib/supabase' -import { DataEntryInterface } from './DataEntryInterface' +import { experimentManagement, repetitionManagement, userManagement, type Experiment, type ExperimentRepetition, type User } from '../lib/supabase' +import { RepetitionDataEntryInterface } from './RepetitionDataEntryInterface' export function DataEntry() { const [experiments, setExperiments] = useState([]) - const [selectedExperiment, setSelectedExperiment] = useState(null) + const [experimentRepetitions, setExperimentRepetitions] = useState>({}) + const [selectedRepetition, setSelectedRepetition] = useState<{ experiment: Experiment; repetition: ExperimentRepetition } | null>(null) const [currentUser, setCurrentUser] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -25,6 +26,19 @@ export function DataEntry() { setExperiments(experimentsData) setCurrentUser(userData) + + // Load repetitions for each experiment + const repetitionsMap: Record = {} + for (const experiment of experimentsData) { + try { + const repetitions = await repetitionManagement.getExperimentRepetitions(experiment.id) + repetitionsMap[experiment.id] = repetitions + } catch (err) { + console.error(`Failed to load repetitions for experiment ${experiment.id}:`, err) + repetitionsMap[experiment.id] = [] + } + } + setExperimentRepetitions(repetitionsMap) } catch (err: any) { setError(err.message || 'Failed to load data') console.error('Load data error:', err) @@ -33,12 +47,49 @@ export function DataEntry() { } } - const handleExperimentSelect = (experiment: Experiment) => { - setSelectedExperiment(experiment) + const handleRepetitionSelect = (experiment: Experiment, repetition: ExperimentRepetition) => { + setSelectedRepetition({ experiment, repetition }) } const handleBackToList = () => { - setSelectedExperiment(null) + setSelectedRepetition(null) + } + + const getAllRepetitionsWithExperiments = () => { + const allRepetitions: Array<{ experiment: Experiment; repetition: ExperimentRepetition }> = [] + + experiments.forEach(experiment => { + const repetitions = experimentRepetitions[experiment.id] || [] + repetitions.forEach(repetition => { + allRepetitions.push({ experiment, repetition }) + }) + }) + + return allRepetitions + } + + const categorizeRepetitions = () => { + const allRepetitions = getAllRepetitionsWithExperiments() + const now = new Date() + + const past = allRepetitions.filter(({ repetition }) => + repetition.completion_status || (repetition.scheduled_date && new Date(repetition.scheduled_date) < now) + ) + + const inProgress = allRepetitions.filter(({ repetition }) => + !repetition.completion_status && + repetition.scheduled_date && + new Date(repetition.scheduled_date) <= now && + new Date(repetition.scheduled_date) > new Date(now.getTime() - 24 * 60 * 60 * 1000) + ) + + const upcoming = allRepetitions.filter(({ repetition }) => + !repetition.completion_status && + repetition.scheduled_date && + new Date(repetition.scheduled_date) > now + ) + + return { past, inProgress, upcoming } } if (loading) { @@ -64,10 +115,11 @@ export function DataEntry() { ) } - if (selectedExperiment) { + if (selectedRepetition) { return ( - @@ -79,76 +131,197 @@ export function DataEntry() {

Data Entry

- Select an experiment to enter measurement data + Select a repetition to enter measurement data

- {/* Experiments List */} -
-
-

- Available Experiments ({experiments.length}) -

-

- Click on any experiment to start entering data -

-
+ {/* Repetitions organized by status - flat list */} + {(() => { + const { past: pastRepetitions, inProgress: inProgressRepetitions, upcoming: upcomingRepetitions } = categorizeRepetitions() - {experiments.length === 0 ? ( -
-
- No experiments available for data entry + return ( +
+ {/* Past/Completed Repetitions */} +
+
+

+ + Past/Completed ({pastRepetitions.length}) +

+

+ Completed or past scheduled repetitions +

+
+
+
+ {pastRepetitions.map(({ experiment, repetition }) => ( + + ))} + {pastRepetitions.length === 0 && ( +

+ No completed repetitions +

+ )} +
+
+
+ + {/* In Progress Repetitions */} +
+
+

+ + In Progress ({inProgressRepetitions.length}) +

+

+ Currently scheduled or active repetitions +

+
+
+
+ {inProgressRepetitions.map(({ experiment, repetition }) => ( + + ))} + {inProgressRepetitions.length === 0 && ( +

+ No repetitions in progress +

+ )} +
+
+
+ + {/* Upcoming Repetitions */} +
+
+

+ + Upcoming ({upcomingRepetitions.length}) +

+

+ Future scheduled repetitions +

+
+
+
+ {upcomingRepetitions.map(({ experiment, repetition }) => ( + + ))} + {upcomingRepetitions.length === 0 && ( +

+ No upcoming repetitions +

+ )} +
+
- ) : ( -
    - {experiments.map((experiment) => ( -
  • - -
  • - ))} -
- )} -
+ ) + })()} + + {experiments.length === 0 && ( +
+
+ No experiments available for data entry +
+
+ )}
) } + +// RepetitionCard component for displaying individual repetitions +interface RepetitionCardProps { + experiment: Experiment + repetition: ExperimentRepetition + onSelect: (experiment: Experiment, repetition: ExperimentRepetition) => void + status: 'past' | 'in-progress' | 'upcoming' +} + +function RepetitionCard({ experiment, repetition, onSelect, status }: RepetitionCardProps) { + const getStatusColor = () => { + switch (status) { + case 'past': + return 'border-green-200 bg-green-50 hover:bg-green-100' + case 'in-progress': + return 'border-blue-200 bg-blue-50 hover:bg-blue-100' + case 'upcoming': + return 'border-yellow-200 bg-yellow-50 hover:bg-yellow-100' + default: + return 'border-gray-200 bg-gray-50 hover:bg-gray-100' + } + } + + const getStatusIcon = () => { + switch (status) { + case 'past': + return '✓' + case 'in-progress': + return '▶' + case 'upcoming': + return '⏰' + default: + return '○' + } + } + + return ( + + ) +} diff --git a/src/components/DataEntryInterface.tsx b/src/components/DataEntryInterface.tsx index f51d21b..6aca99f 100644 --- a/src/components/DataEntryInterface.tsx +++ b/src/components/DataEntryInterface.tsx @@ -1,8 +1,10 @@ import { useState, useEffect } from 'react' -import { dataEntryManagement, type Experiment, type ExperimentDataEntry, type User, type ExperimentPhase } from '../lib/supabase' +import { type Experiment, type User, type ExperimentPhase } from '../lib/supabase' import { DraftManager } from './DraftManager' import { PhaseSelector } from './PhaseSelector' -import { PhaseDataEntry } from './PhaseDataEntry' + +// DEPRECATED: This component is deprecated in favor of RepetitionDataEntryInterface +// which uses the new phase-specific draft system interface DataEntryInterfaceProps { experiment: Experiment @@ -10,9 +12,21 @@ interface DataEntryInterfaceProps { onBack: () => void } +// Temporary type for backward compatibility +interface LegacyDataEntry { + id: string + experiment_id: string + user_id: string + status: 'draft' | 'submitted' + entry_name?: string | null + created_at: string + updated_at: string + submitted_at?: string | null +} + export function DataEntryInterface({ experiment, currentUser, onBack }: DataEntryInterfaceProps) { - const [userDataEntries, setUserDataEntries] = useState([]) - const [selectedDataEntry, setSelectedDataEntry] = useState(null) + const [userDataEntries, setUserDataEntries] = useState([]) + const [selectedDataEntry, setSelectedDataEntry] = useState(null) const [selectedPhase, setSelectedPhase] = useState(null) const [showDraftManager, setShowDraftManager] = useState(false) const [loading, setLoading] = useState(true) @@ -27,7 +41,8 @@ export function DataEntryInterface({ experiment, currentUser, onBack }: DataEntr setLoading(true) setError(null) - const entries = await dataEntryManagement.getUserDataEntriesForExperiment(experiment.id) + // DEPRECATED: Using empty array since this component is deprecated + const entries: LegacyDataEntry[] = [] setUserDataEntries(entries) // Auto-select the most recent draft or create a new one @@ -47,58 +62,21 @@ export function DataEntryInterface({ experiment, currentUser, onBack }: DataEntr } const handleCreateNewDraft = async () => { - try { - const newEntry = await dataEntryManagement.createDataEntry({ - experiment_id: experiment.id, - entry_name: `Draft ${new Date().toLocaleString()}`, - status: 'draft' - }) - - setUserDataEntries(prev => [newEntry, ...prev]) - setSelectedDataEntry(newEntry) - setShowDraftManager(false) - } catch (err: any) { - setError(err.message || 'Failed to create new draft') - } + setError('This component is deprecated. Please use the new repetition-based data entry system.') } - const handleSelectDataEntry = (entry: ExperimentDataEntry) => { + const handleSelectDataEntry = (entry: LegacyDataEntry) => { setSelectedDataEntry(entry) setShowDraftManager(false) setSelectedPhase(null) } - const handleDeleteDraft = async (entryId: string) => { - try { - await dataEntryManagement.deleteDataEntry(entryId) - setUserDataEntries(prev => prev.filter(entry => entry.id !== entryId)) - - // If we deleted the currently selected entry, select another or create new - if (selectedDataEntry?.id === entryId) { - const remainingDrafts = userDataEntries.filter(entry => entry.id !== entryId && entry.status === 'draft') - if (remainingDrafts.length > 0) { - setSelectedDataEntry(remainingDrafts[0]) - } else { - await handleCreateNewDraft() - } - } - } catch (err: any) { - setError(err.message || 'Failed to delete draft') - } + const handleDeleteDraft = async (_entryId: string) => { + setError('This component is deprecated. Please use the new repetition-based data entry system.') } - const handleSubmitEntry = async (entryId: string) => { - try { - const submittedEntry = await dataEntryManagement.submitDataEntry(entryId) - setUserDataEntries(prev => prev.map(entry => - entry.id === entryId ? submittedEntry : entry - )) - - // Create a new draft for continued work - await handleCreateNewDraft() - } catch (err: any) { - setError(err.message || 'Failed to submit entry') - } + const handleSubmitEntry = async (_entryId: string) => { + setError('This component is deprecated. Please use the new repetition-based data entry system.') } const handlePhaseSelect = (phase: ExperimentPhase) => { @@ -195,14 +173,7 @@ export function DataEntryInterface({ experiment, currentUser, onBack }: DataEntr
- {experiment.scheduled_date && ( -
- Scheduled: - - {new Date(experiment.scheduled_date).toLocaleString()} - -
- )} + {/* Scheduled date removed - this is now handled at repetition level */} {/* Current Draft Info */} @@ -237,17 +208,12 @@ export function DataEntryInterface({ experiment, currentUser, onBack }: DataEntr onCreateNew={handleCreateNewDraft} onClose={() => setShowDraftManager(false)} /> - ) : selectedPhase && selectedDataEntry ? ( - { - // Refresh data entries to show updated timestamps - loadUserDataEntries() - }} - /> + ) : selectedPhase ? ( +
+
+ This component is deprecated. Please use the new repetition-based data entry system. +
+
) : selectedDataEntry ? ( void + userDataEntries: LegacyDataEntry[] + selectedDataEntry: LegacyDataEntry | null + onSelectEntry: (entry: LegacyDataEntry) => void onDeleteDraft: (entryId: string) => void onCreateNew: () => void onClose: () => void @@ -64,11 +76,10 @@ export function DraftManager({ {drafts.map((entry) => (
diff --git a/src/components/Experiments.tsx b/src/components/Experiments.tsx index c1b2f70..ea11e53 100644 --- a/src/components/Experiments.tsx +++ b/src/components/Experiments.tsx @@ -1,19 +1,20 @@ import { useState, useEffect } from 'react' import { ExperimentModal } from './ExperimentModal' -import { ScheduleModal } from './ScheduleModal' -import { experimentManagement, userManagement } from '../lib/supabase' -import type { Experiment, User, ScheduleStatus, ResultsStatus } from '../lib/supabase' +import { RepetitionScheduleModal } from './RepetitionScheduleModal' +import { experimentManagement, repetitionManagement, userManagement } from '../lib/supabase' +import type { Experiment, ExperimentRepetition, User, ScheduleStatus, ResultsStatus } from '../lib/supabase' export function Experiments() { const [experiments, setExperiments] = useState([]) + const [experimentRepetitions, setExperimentRepetitions] = useState>({}) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [showModal, setShowModal] = useState(false) const [editingExperiment, setEditingExperiment] = useState(undefined) const [currentUser, setCurrentUser] = useState(null) - const [filterStatus, setFilterStatus] = useState('all') - const [showScheduleModal, setShowScheduleModal] = useState(false) - const [schedulingExperiment, setSchedulingExperiment] = useState(undefined) + + const [showRepetitionScheduleModal, setShowRepetitionScheduleModal] = useState(false) + const [schedulingRepetition, setSchedulingRepetition] = useState<{ experiment: Experiment; repetition: ExperimentRepetition } | undefined>(undefined) useEffect(() => { loadData() @@ -31,6 +32,19 @@ export function Experiments() { setExperiments(experimentsData) setCurrentUser(userData) + + // Load repetitions for each experiment + const repetitionsMap: Record = {} + for (const experiment of experimentsData) { + try { + const repetitions = await repetitionManagement.getExperimentRepetitions(experiment.id) + repetitionsMap[experiment.id] = repetitions + } catch (err) { + console.error(`Failed to load repetitions for experiment ${experiment.id}:`, err) + repetitionsMap[experiment.id] = [] + } + } + setExperimentRepetitions(repetitionsMap) } catch (err: any) { setError(err.message || 'Failed to load experiments') console.error('Load experiments error:', err) @@ -51,29 +65,60 @@ export function Experiments() { setShowModal(true) } - const handleExperimentSaved = (experiment: Experiment) => { + const handleExperimentSaved = async (experiment: Experiment) => { if (editingExperiment) { // Update existing experiment setExperiments(prev => prev.map(exp => exp.id === experiment.id ? experiment : exp)) } else { - // Add new experiment + // Add new experiment and create all its repetitions setExperiments(prev => [experiment, ...prev]) + + try { + // Create all repetitions for the new experiment + const repetitions = await repetitionManagement.createAllRepetitions(experiment.id) + setExperimentRepetitions(prev => ({ + ...prev, + [experiment.id]: repetitions + })) + } catch (err) { + console.error('Failed to create repetitions:', err) + } } setShowModal(false) setEditingExperiment(undefined) } - const handleScheduleExperiment = (experiment: Experiment) => { - setSchedulingExperiment(experiment) - setShowScheduleModal(true) + const handleScheduleRepetition = (experiment: Experiment, repetition: ExperimentRepetition) => { + setSchedulingRepetition({ experiment, repetition }) + setShowRepetitionScheduleModal(true) } - const handleScheduleUpdated = (updatedExperiment: Experiment) => { - setExperiments(prev => prev.map(exp => - exp.id === updatedExperiment.id ? updatedExperiment : exp - )) - setShowScheduleModal(false) - setSchedulingExperiment(undefined) + const handleRepetitionScheduleUpdated = (updatedRepetition: ExperimentRepetition) => { + setExperimentRepetitions(prev => ({ + ...prev, + [updatedRepetition.experiment_id]: prev[updatedRepetition.experiment_id]?.map(rep => + rep.id === updatedRepetition.id ? updatedRepetition : rep + ) || [] + })) + setShowRepetitionScheduleModal(false) + setSchedulingRepetition(undefined) + } + + const handleCreateRepetition = async (experiment: Experiment, repetitionNumber: number) => { + try { + const newRepetition = await repetitionManagement.createRepetition({ + experiment_id: experiment.id, + repetition_number: repetitionNumber, + schedule_status: 'pending schedule' + }) + + setExperimentRepetitions(prev => ({ + ...prev, + [experiment.id]: [...(prev[experiment.id] || []), newRepetition].sort((a, b) => a.repetition_number - b.repetition_number) + })) + } catch (err: any) { + setError(err.message || 'Failed to create repetition') + } } const handleDeleteExperiment = async (experiment: Experiment) => { @@ -95,18 +140,12 @@ export function Experiments() { } } - const handleStatusUpdate = async (experiment: Experiment, scheduleStatus?: ScheduleStatus, resultsStatus?: ResultsStatus) => { - try { - const updatedExperiment = await experimentManagement.updateExperimentStatus( - experiment.id, - scheduleStatus, - resultsStatus - ) - setExperiments(prev => prev.map(exp => exp.id === experiment.id ? updatedExperiment : exp)) - } catch (err: any) { - alert(`Failed to update status: ${err.message}`) - console.error('Update status error:', err) - } + const getRepetitionStatusSummary = (repetitions: ExperimentRepetition[]) => { + const scheduled = repetitions.filter(r => r.schedule_status === 'scheduled').length + const pending = repetitions.filter(r => r.schedule_status === 'pending schedule').length + const completed = repetitions.filter(r => r.completion_status).length + + return { scheduled, pending, completed, total: repetitions.length } } const getStatusBadgeColor = (status: ScheduleStatus | ResultsStatus) => { @@ -128,9 +167,8 @@ export function Experiments() { } } - const filteredExperiments = filterStatus === 'all' - ? experiments - : experiments.filter(exp => exp.schedule_status === filterStatus) + // Remove filtering for now since experiments don't have schedule_status anymore + const filteredExperiments = experiments if (loading) { return ( @@ -150,6 +188,7 @@ export function Experiments() {

Experiments

Manage pecan processing experiment definitions

+

This is where you define the blueprint of an experiment with the required configurations and parameters, as well as the number of repetitions needed for that experiment.

{canManageExperiments && ( - {experiment.scheduled_date && ( - - {new Date(experiment.scheduled_date).toLocaleString()} - - )} +
+ {(() => { + const repetitions = experimentRepetitions[experiment.id] || [] + return repetitions.map((repetition) => ( +
+ Rep #{repetition.repetition_number} +
+ + {repetition.schedule_status === 'pending schedule' ? 'Pending' : repetition.schedule_status} + + +
+
+ )) + })()} + {(() => { + const repetitions = experimentRepetitions[experiment.id] || [] + const missingReps = experiment.reps_required - repetitions.length + if (missingReps > 0) { + return ( + + ) + } + return null + })()}
)} @@ -292,8 +358,8 @@ export function Experiments() { {experiment.completion_status ? 'Completed' : 'In Progress'} @@ -340,11 +406,9 @@ export function Experiments() {

No experiments found

- {filterStatus === 'all' - ? 'Get started by creating your first experiment.' - : `No experiments with status "${filterStatus}".`} + Get started by creating your first experiment.

- {canManageExperiments && filterStatus === 'all' && ( + {canManageExperiments && (
diff --git a/src/components/PhaseDataEntry.tsx b/src/components/PhaseDataEntry.tsx index 1cebbfb..7782f64 100644 --- a/src/components/PhaseDataEntry.tsx +++ b/src/components/PhaseDataEntry.tsx @@ -1,31 +1,62 @@ import { useState, useEffect, useCallback } from 'react' -import { dataEntryManagement, type Experiment, type ExperimentDataEntry, type ExperimentPhase, type ExperimentPhaseData } from '../lib/supabase' +import { phaseDraftManagement, type Experiment, type ExperimentPhaseDraft, type ExperimentPhase, type ExperimentPhaseData, type ExperimentRepetition, type User } from '../lib/supabase' +import { PhaseDraftManager } from './PhaseDraftManager' interface PhaseDataEntryProps { experiment: Experiment - dataEntry: ExperimentDataEntry + repetition: ExperimentRepetition phase: ExperimentPhase + currentUser: User onBack: () => void onDataSaved: () => void } -export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSaved }: PhaseDataEntryProps) { +export function PhaseDataEntry({ experiment, repetition, phase, currentUser, onBack, onDataSaved }: PhaseDataEntryProps) { + const [selectedDraft, setSelectedDraft] = useState(null) const [phaseData, setPhaseData] = useState>({}) const [diameterMeasurements, setDiameterMeasurements] = useState(Array(10).fill(0)) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const [lastSaved, setLastSaved] = useState(null) + const [showDraftManager, setShowDraftManager] = useState(false) // Auto-save interval (30 seconds) const AUTO_SAVE_INTERVAL = 30000 - const loadPhaseData = useCallback(async () => { + useEffect(() => { + loadUserDrafts() + }, [repetition.id, phase]) + + const loadUserDrafts = async () => { try { setLoading(true) setError(null) - const existingData = await dataEntryManagement.getPhaseData(dataEntry.id, phase) + const drafts = await phaseDraftManagement.getUserPhaseDraftsForPhase(repetition.id, phase) + + // Auto-select the most recent draft or show draft manager if none exist + if (drafts.length > 0) { + const mostRecentDraft = drafts[0] // Already sorted by created_at desc + setSelectedDraft(mostRecentDraft) + await loadPhaseDataForDraft(mostRecentDraft) + } else { + setShowDraftManager(true) + } + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load drafts' + setError(errorMessage) + console.error('Load drafts error:', err) + } finally { + setLoading(false) + } + } + + const loadPhaseDataForDraft = async (draft: ExperimentPhaseDraft) => { + try { + setError(null) + + const existingData = await phaseDraftManagement.getPhaseDataForDraft(draft.id) if (existingData) { setPhaseData(existingData) @@ -43,33 +74,32 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav } else { // Initialize empty phase data setPhaseData({ - data_entry_id: dataEntry.id, + phase_draft_id: draft.id, phase_name: phase }) + setDiameterMeasurements(Array(10).fill(0)) } } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : 'Failed to load phase data' setError(errorMessage) console.error('Load phase data error:', err) - } finally { - setLoading(false) } - }, [dataEntry.id, phase]) + } const autoSave = useCallback(async () => { - if (dataEntry.status === 'submitted') return // Don't auto-save submitted entries + if (!selectedDraft || selectedDraft.status === 'submitted') return // Don't auto-save submitted drafts try { - await dataEntryManagement.autoSaveDraft(dataEntry.id, phase, phaseData) + await phaseDraftManagement.autoSaveDraft(selectedDraft.id, phaseData) // Save diameter measurements if this is air-drying phase and we have measurements if (phase === 'air-drying' && phaseData.id && diameterMeasurements.some(m => m > 0)) { const validMeasurements = diameterMeasurements.filter(m => m > 0) if (validMeasurements.length > 0) { - await dataEntryManagement.saveDiameterMeasurements(phaseData.id, diameterMeasurements) + await phaseDraftManagement.saveDiameterMeasurements(phaseData.id, diameterMeasurements) // Update average diameter - const avgDiameter = dataEntryManagement.calculateAverageDiameter(validMeasurements) + const avgDiameter = phaseDraftManagement.calculateAverageDiameter(validMeasurements) setPhaseData(prev => ({ ...prev, avg_pecan_diameter_in: avgDiameter })) } } @@ -78,22 +108,18 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav } catch (error) { console.warn('Auto-save failed:', error) } - }, [dataEntry.id, dataEntry.status, phase, phaseData, diameterMeasurements]) - - useEffect(() => { - loadPhaseData() - }, [loadPhaseData]) + }, [selectedDraft, phase, phaseData, diameterMeasurements]) // Auto-save effect useEffect(() => { - if (!loading && phaseData.id) { + if (!loading && selectedDraft && phaseData.phase_draft_id) { const interval = setInterval(() => { autoSave() }, AUTO_SAVE_INTERVAL) return () => clearInterval(interval) } - }, [phaseData, diameterMeasurements, loading, autoSave]) + }, [phaseData, diameterMeasurements, loading, autoSave, selectedDraft]) const handleInputChange = (field: string, value: unknown) => { setPhaseData(prev => ({ @@ -110,23 +136,25 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav // Calculate and update average const validMeasurements = newMeasurements.filter(m => m > 0) if (validMeasurements.length > 0) { - const avgDiameter = dataEntryManagement.calculateAverageDiameter(validMeasurements) + const avgDiameter = phaseDraftManagement.calculateAverageDiameter(validMeasurements) handleInputChange('avg_pecan_diameter_in', avgDiameter) } } const handleSave = async () => { + if (!selectedDraft) return + try { setSaving(true) setError(null) // Save phase data - const savedData = await dataEntryManagement.upsertPhaseData(dataEntry.id, phase, phaseData) + const savedData = await phaseDraftManagement.upsertPhaseData(selectedDraft.id, phaseData) setPhaseData(savedData) // Save diameter measurements if this is air-drying phase if (phase === 'air-drying' && diameterMeasurements.some(m => m > 0)) { - await dataEntryManagement.saveDiameterMeasurements(savedData.id, diameterMeasurements) + await phaseDraftManagement.saveDiameterMeasurements(savedData.id, diameterMeasurements) } setLastSaved(new Date()) @@ -140,6 +168,20 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav } } + const handleSelectDraft = (draft: ExperimentPhaseDraft) => { + setSelectedDraft(draft) + setShowDraftManager(false) + loadPhaseDataForDraft(draft) + } + + const isFieldDisabled = () => { + const isAdmin = currentUser.roles.includes('admin') + return !selectedDraft || + selectedDraft.status === 'submitted' || + selectedDraft.status === 'withdrawn' || + (repetition.is_locked && !isAdmin) + } + const getPhaseTitle = () => { switch (phase) { case 'pre-soaking': return 'Pre-Soaking Phase' @@ -181,6 +223,17 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav return (
+ {/* Draft Manager Modal */} + {showDraftManager && ( + setShowDraftManager(false)} + /> + )} + {/* Header */}
@@ -195,8 +248,32 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav Back to Phases

{getPhaseTitle()}

+ {selectedDraft && ( +
+ + Draft: {selectedDraft.draft_name || `Draft ${selectedDraft.id.slice(-8)}`} + + + {selectedDraft.status} + + {repetition.is_locked && ( + + 🔒 Locked + + )} +
+ )}
+ {lastSaved && ( Last saved: {lastSaved.toLocaleTimeString()} @@ -204,7 +281,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav )}
)} - {dataEntry.status === 'submitted' && ( + {selectedDraft?.status === 'submitted' && (
- This entry has been submitted and is read-only. Create a new draft to make changes. + This draft has been submitted and is read-only. Create a new draft to make changes. +
+
+ )} + + {selectedDraft?.status === 'withdrawn' && ( +
+
+ This draft has been withdrawn. Create a new draft to make changes. +
+
+ )} + + {repetition.is_locked && !currentUser.roles.includes('admin') && ( +
+
+ This repetition has been locked by an admin. No changes can be made to drafts. +
+
+ )} + + {repetition.is_locked && currentUser.roles.includes('admin') && ( +
+
+ 🔒 This repetition is locked, but you can still make changes as an admin. +
+
+ )} + + {!selectedDraft && ( +
+
+ No draft selected. Use "Manage Drafts" to create or select a draft for this phase.
)} @@ -246,7 +355,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav onChange={(e) => handleInputChange('batch_initial_weight_lbs', parseFloat(e.target.value) || null)} className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="0.00" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} />
@@ -263,7 +372,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav onChange={(e) => handleInputChange('initial_shell_moisture_pct', parseFloat(e.target.value) || null)} className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="0.0" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} />
@@ -280,7 +389,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav onChange={(e) => handleInputChange('initial_kernel_moisture_pct', parseFloat(e.target.value) || null)} className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="0.0" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} />
@@ -293,7 +402,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav value={phaseData.soaking_start_time ? new Date(phaseData.soaking_start_time).toISOString().slice(0, 16) : ''} onChange={(e) => handleInputChange('soaking_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)} className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} />
@@ -332,7 +441,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav value={phaseData.airdrying_start_time ? new Date(phaseData.airdrying_start_time).toISOString().slice(0, 16) : ''} onChange={(e) => handleInputChange('airdrying_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)} className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} /> @@ -348,7 +457,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav onChange={(e) => handleInputChange('post_soak_weight_lbs', parseFloat(e.target.value) || null)} className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="0.00" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} /> @@ -365,7 +474,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav onChange={(e) => handleInputChange('post_soak_kernel_moisture_pct', parseFloat(e.target.value) || null)} className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="0.0" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} /> @@ -382,7 +491,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav onChange={(e) => handleInputChange('post_soak_shell_moisture_pct', parseFloat(e.target.value) || null)} className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="0.0" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} /> @@ -422,7 +531,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav onChange={(e) => handleDiameterChange(index, parseFloat(e.target.value) || 0)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="0.000" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} /> ))} @@ -440,7 +549,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav onChange={(e) => handleInputChange('avg_pecan_diameter_in', parseFloat(e.target.value) || null)} className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="0.000" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} />

Automatically calculated from individual measurements above @@ -464,7 +573,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav value={phaseData.cracking_start_time ? new Date(phaseData.cracking_start_time).toISOString().slice(0, 16) : ''} onChange={(e) => handleInputChange('cracking_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)} className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} /> @@ -536,7 +645,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav value={phaseData.shelling_start_time ? new Date(phaseData.shelling_start_time).toISOString().slice(0, 16) : ''} onChange={(e) => handleInputChange('shelling_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)} className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} /> @@ -557,7 +666,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav onChange={(e) => handleInputChange('bin_1_weight_lbs', parseFloat(e.target.value) || null)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="0.00" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} />

@@ -572,7 +681,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav onChange={(e) => handleInputChange('bin_2_weight_lbs', parseFloat(e.target.value) || null)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="0.00" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} />
@@ -587,7 +696,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav onChange={(e) => handleInputChange('bin_3_weight_lbs', parseFloat(e.target.value) || null)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="0.00" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} />
@@ -602,7 +711,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav onChange={(e) => handleInputChange('discharge_bin_weight_lbs', parseFloat(e.target.value) || null)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="0.00" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} />
@@ -624,7 +733,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav onChange={(e) => handleInputChange('bin_1_full_yield_oz', parseFloat(e.target.value) || null)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="0.00" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} />
@@ -639,7 +748,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav onChange={(e) => handleInputChange('bin_2_full_yield_oz', parseFloat(e.target.value) || null)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="0.00" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} />
@@ -654,7 +763,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav onChange={(e) => handleInputChange('bin_3_full_yield_oz', parseFloat(e.target.value) || null)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="0.00" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} />
@@ -676,7 +785,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav onChange={(e) => handleInputChange('bin_1_half_yield_oz', parseFloat(e.target.value) || null)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="0.00" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} />
@@ -691,7 +800,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav onChange={(e) => handleInputChange('bin_2_half_yield_oz', parseFloat(e.target.value) || null)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="0.00" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} />
@@ -706,7 +815,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav onChange={(e) => handleInputChange('bin_3_half_yield_oz', parseFloat(e.target.value) || null)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="0.00" - disabled={dataEntry.status === 'submitted'} + disabled={isFieldDisabled()} />
diff --git a/src/components/PhaseDraftManager.tsx b/src/components/PhaseDraftManager.tsx new file mode 100644 index 0000000..abec894 --- /dev/null +++ b/src/components/PhaseDraftManager.tsx @@ -0,0 +1,276 @@ +import { useState, useEffect } from 'react' +import { phaseDraftManagement, type ExperimentPhaseDraft, type ExperimentPhase, type User, type ExperimentRepetition } from '../lib/supabase' + +interface PhaseDraftManagerProps { + repetition: ExperimentRepetition + phase: ExperimentPhase + currentUser: User + onSelectDraft: (draft: ExperimentPhaseDraft) => void + onClose: () => void +} + +export function PhaseDraftManager({ repetition, phase, currentUser, onSelectDraft, onClose }: PhaseDraftManagerProps) { + const [drafts, setDrafts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [creating, setCreating] = useState(false) + const [newDraftName, setNewDraftName] = useState('') + + useEffect(() => { + loadDrafts() + }, [repetition.id, phase]) + + const loadDrafts = async () => { + try { + setLoading(true) + setError(null) + const userDrafts = await phaseDraftManagement.getUserPhaseDraftsForPhase(repetition.id, phase) + setDrafts(userDrafts) + } catch (err: any) { + setError(err.message || 'Failed to load drafts') + console.error('Load drafts error:', err) + } finally { + setLoading(false) + } + } + + const handleCreateDraft = async () => { + try { + setCreating(true) + setError(null) + + const newDraft = await phaseDraftManagement.createPhaseDraft({ + experiment_id: repetition.experiment_id, + repetition_id: repetition.id, + phase_name: phase, + draft_name: newDraftName || undefined, + status: 'draft' + }) + + setDrafts(prev => [newDraft, ...prev]) + setNewDraftName('') + onSelectDraft(newDraft) + } catch (err: any) { + setError(err.message || 'Failed to create draft') + } finally { + setCreating(false) + } + } + + const handleDeleteDraft = async (draftId: string) => { + if (!confirm('Are you sure you want to delete this draft? This action cannot be undone.')) { + return + } + + try { + await phaseDraftManagement.deletePhaseDraft(draftId) + setDrafts(prev => prev.filter(draft => draft.id !== draftId)) + } catch (err: any) { + setError(err.message || 'Failed to delete draft') + } + } + + const handleSubmitDraft = async (draftId: string) => { + if (!confirm('Are you sure you want to submit this draft? Once submitted, it can only be withdrawn by you or locked by an admin.')) { + return + } + + try { + const submittedDraft = await phaseDraftManagement.submitPhaseDraft(draftId) + setDrafts(prev => prev.map(draft => + draft.id === draftId ? submittedDraft : draft + )) + } catch (err: any) { + setError(err.message || 'Failed to submit draft') + } + } + + const handleWithdrawDraft = async (draftId: string) => { + if (!confirm('Are you sure you want to withdraw this submitted draft? It will be marked as withdrawn.')) { + return + } + + try { + const withdrawnDraft = await phaseDraftManagement.withdrawPhaseDraft(draftId) + setDrafts(prev => prev.map(draft => + draft.id === draftId ? withdrawnDraft : draft + )) + } catch (err: any) { + setError(err.message || 'Failed to withdraw draft') + } + } + + const getStatusBadge = (status: string) => { + switch (status) { + case 'draft': + return Draft + case 'submitted': + return Submitted + case 'withdrawn': + return Withdrawn + default: + return {status} + } + } + + const canDeleteDraft = (draft: ExperimentPhaseDraft) => { + return draft.status === 'draft' && (!repetition.is_locked || currentUser.roles.includes('admin')) + } + + const canSubmitDraft = (draft: ExperimentPhaseDraft) => { + return draft.status === 'draft' && (!repetition.is_locked || currentUser.roles.includes('admin')) + } + + const canWithdrawDraft = (draft: ExperimentPhaseDraft) => { + return draft.status === 'submitted' && (!repetition.is_locked || currentUser.roles.includes('admin')) + } + + const canCreateDraft = () => { + return !repetition.is_locked || currentUser.roles.includes('admin') + } + + const formatPhaseTitle = (phase: string) => { + return phase.split('-').map(word => + word.charAt(0).toUpperCase() + word.slice(1) + ).join(' ') + } + + return ( +
+
+
+
+

+ {formatPhaseTitle(phase)} Phase Drafts +

+

+ Repetition {repetition.repetition_number} + {repetition.is_locked && ( + + 🔒 Locked + + )} +

+
+ +
+ +
+ {error && ( +
+
{error}
+
+ )} + + {/* Create New Draft */} +
+

Create New Draft

+
+ setNewDraftName(e.target.value)} + className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + disabled={creating || repetition.is_locked} + /> + +
+ {repetition.is_locked && !currentUser.roles.includes('admin') && ( +

+ Cannot create new drafts: repetition is locked by admin +

+ )} +
+ + {/* Drafts List */} +
+ {loading ? ( +
+
Loading drafts...
+
+ ) : drafts.length === 0 ? ( +
+
No drafts found for this phase
+

Create a new draft to get started

+
+ ) : ( + drafts.map((draft) => ( +
+
+
+
+

+ {draft.draft_name || `Draft ${draft.id.slice(-8)}`} +

+ {getStatusBadge(draft.status)} +
+
+

Created: {new Date(draft.created_at).toLocaleString()}

+

Updated: {new Date(draft.updated_at).toLocaleString()}

+ {draft.submitted_at && ( +

Submitted: {new Date(draft.submitted_at).toLocaleString()}

+ )} + {draft.withdrawn_at && ( +

Withdrawn: {new Date(draft.withdrawn_at).toLocaleString()}

+ )} +
+
+
+ + + {canSubmitDraft(draft) && ( + + )} + + {canWithdrawDraft(draft) && ( + + )} + + {canDeleteDraft(draft) && ( + + )} +
+
+
+ )) + )} +
+
+
+
+ ) +} diff --git a/src/components/PhaseSelector.tsx b/src/components/PhaseSelector.tsx index 4488f58..827df89 100644 --- a/src/components/PhaseSelector.tsx +++ b/src/components/PhaseSelector.tsx @@ -1,8 +1,23 @@ import { useState, useEffect } from 'react' -import { dataEntryManagement, type ExperimentDataEntry, type ExperimentPhase, type ExperimentPhaseData } from '../lib/supabase' +import { type ExperimentPhase, type ExperimentPhaseData } from '../lib/supabase' + +// DEPRECATED: This component is deprecated in favor of RepetitionPhaseSelector +// which uses the new phase-specific draft system + +// Temporary type for backward compatibility +interface LegacyDataEntry { + id: string + experiment_id: string + user_id: string + status: 'draft' | 'submitted' + entry_name?: string | null + created_at: string + updated_at: string + submitted_at?: string | null +} interface PhaseSelectorProps { - dataEntry: ExperimentDataEntry + dataEntry: LegacyDataEntry onPhaseSelect: (phase: ExperimentPhase) => void } @@ -61,7 +76,8 @@ export function PhaseSelector({ dataEntry, onPhaseSelect }: PhaseSelectorProps) const loadPhaseData = async () => { try { setLoading(true) - const allPhaseData = await dataEntryManagement.getPhaseDataForEntry(dataEntry.id) + // DEPRECATED: Using empty array since this component is deprecated + const allPhaseData: ExperimentPhaseData[] = [] const phaseDataMap: Record = { 'pre-soaking': null, diff --git a/src/components/RepetitionDataEntryInterface.tsx b/src/components/RepetitionDataEntryInterface.tsx new file mode 100644 index 0000000..9cf9e8e --- /dev/null +++ b/src/components/RepetitionDataEntryInterface.tsx @@ -0,0 +1,115 @@ +import { useState, useEffect } from 'react' +import { type Experiment, type ExperimentRepetition, type User, type ExperimentPhase } from '../lib/supabase' +import { RepetitionPhaseSelector } from './RepetitionPhaseSelector' +import { PhaseDataEntry } from './PhaseDataEntry' +import { RepetitionLockManager } from './RepetitionLockManager' + +interface RepetitionDataEntryInterfaceProps { + experiment: Experiment + repetition: ExperimentRepetition + currentUser: User + onBack: () => void +} + +export function RepetitionDataEntryInterface({ experiment, repetition, currentUser, onBack }: RepetitionDataEntryInterfaceProps) { + const [selectedPhase, setSelectedPhase] = useState(null) + const [loading, setLoading] = useState(true) + const [currentRepetition, setCurrentRepetition] = useState(repetition) + + useEffect(() => { + // Skip loading old data entries - go directly to phase selection + setLoading(false) + }, [repetition.id, currentUser.id]) + + + + const handlePhaseSelect = (phase: ExperimentPhase) => { + setSelectedPhase(phase) + } + + const handleBackToPhases = () => { + setSelectedPhase(null) + } + + const handleRepetitionUpdated = (updatedRepetition: ExperimentRepetition) => { + setCurrentRepetition(updatedRepetition) + } + + if (loading) { + return ( +
+
+
+
+

Loading...

+
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+
+
+ +
+ +
+

+ Experiment #{experiment.experiment_number} - Repetition #{repetition.repetition_number} +

+
+
Soaking: {experiment.soaking_duration_hr}h • Air Drying: {experiment.air_drying_time_min}min
+
Frequency: {experiment.plate_contact_frequency_hz}Hz • Throughput: {experiment.throughput_rate_pecans_sec}/sec
+ {repetition.scheduled_date && ( +
Scheduled: {new Date(repetition.scheduled_date).toLocaleString()}
+ )} +
+
+
+ + {/* No additional controls needed - phase-specific draft management is handled within each phase */} +
+
+ + {/* Admin Controls */} + + + {/* Main Content */} + {selectedPhase ? ( + { + // Data is automatically saved in the new phase-specific system + }} + /> + ) : ( + + )} +
+ ) +} diff --git a/src/components/RepetitionLockManager.tsx b/src/components/RepetitionLockManager.tsx new file mode 100644 index 0000000..a83d2ac --- /dev/null +++ b/src/components/RepetitionLockManager.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react' +import { repetitionManagement, type ExperimentRepetition, type User } from '../lib/supabase' + +interface RepetitionLockManagerProps { + repetition: ExperimentRepetition + currentUser: User + onRepetitionUpdated: (updatedRepetition: ExperimentRepetition) => void +} + +export function RepetitionLockManager({ repetition, currentUser, onRepetitionUpdated }: RepetitionLockManagerProps) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const isAdmin = currentUser.roles.includes('admin') + + const handleLockRepetition = async () => { + if (!confirm('Are you sure you want to lock this repetition? This will prevent users from modifying or withdrawing any submitted drafts.')) { + return + } + + try { + setLoading(true) + setError(null) + + const updatedRepetition = await repetitionManagement.lockRepetition(repetition.id) + onRepetitionUpdated(updatedRepetition) + } catch (err: any) { + setError(err.message || 'Failed to lock repetition') + } finally { + setLoading(false) + } + } + + const handleUnlockRepetition = async () => { + if (!confirm('Are you sure you want to unlock this repetition? This will allow users to modify and withdraw submitted drafts again.')) { + return + } + + try { + setLoading(true) + setError(null) + + const updatedRepetition = await repetitionManagement.unlockRepetition(repetition.id) + onRepetitionUpdated(updatedRepetition) + } catch (err: any) { + setError(err.message || 'Failed to unlock repetition') + } finally { + setLoading(false) + } + } + + if (!isAdmin) { + return null + } + + return ( +
+

Admin Controls

+ + {error && ( +
+
{error}
+
+ )} + +
+
+
+ Repetition Status: + {repetition.is_locked ? ( + + 🔒 Locked + + ) : ( + + 🔓 Unlocked + + )} +
+ + {repetition.is_locked && repetition.locked_at && ( +
+ Locked: {new Date(repetition.locked_at).toLocaleString()} +
+ )} +
+ +
+ {repetition.is_locked ? ( + + ) : ( + + )} +
+
+ +
+ {repetition.is_locked ? ( +

+ When locked, users cannot create new drafts, delete existing drafts, or withdraw submitted drafts. + Only admins can modify the lock status. +

+ ) : ( +

+ When unlocked, users can freely create, edit, delete, submit, and withdraw drafts. + Lock this repetition to prevent further changes to submitted data. +

+ )} +
+
+ ) +} diff --git a/src/components/RepetitionPhaseSelector.tsx b/src/components/RepetitionPhaseSelector.tsx new file mode 100644 index 0000000..c07922a --- /dev/null +++ b/src/components/RepetitionPhaseSelector.tsx @@ -0,0 +1,223 @@ +import { useState, useEffect } from 'react' +import { phaseDraftManagement, type ExperimentRepetition, type ExperimentPhase, type ExperimentPhaseDraft, type User } from '../lib/supabase' + +interface RepetitionPhaseSelectorProps { + repetition: ExperimentRepetition + currentUser: User + onPhaseSelect: (phase: ExperimentPhase) => void +} + +interface PhaseInfo { + name: ExperimentPhase + title: string + description: string + icon: string + color: string +} + +const phases: PhaseInfo[] = [ + { + name: 'pre-soaking', + title: 'Pre-Soaking', + description: 'Initial measurements before soaking process', + icon: '🌰', + color: 'bg-blue-500' + }, + { + name: 'air-drying', + title: 'Air-Drying', + description: 'Post-soak measurements and air-drying data', + icon: '💨', + color: 'bg-green-500' + }, + { + name: 'cracking', + title: 'Cracking', + description: 'Cracking process timing and parameters', + icon: '🔨', + color: 'bg-yellow-500' + }, + { + name: 'shelling', + title: 'Shelling', + description: 'Final measurements and yield data', + icon: '📊', + color: 'bg-purple-500' + } +] + +export function RepetitionPhaseSelector({ repetition, currentUser: _currentUser, onPhaseSelect }: RepetitionPhaseSelectorProps) { + const [phaseDrafts, setPhaseDrafts] = useState>({ + 'pre-soaking': [], + 'air-drying': [], + 'cracking': [], + 'shelling': [] + }) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + loadPhaseDrafts() + }, [repetition.id]) + + const loadPhaseDrafts = async () => { + try { + setLoading(true) + setError(null) + + const allDrafts = await phaseDraftManagement.getUserPhaseDraftsForRepetition(repetition.id) + + // Group drafts by phase + const groupedDrafts: Record = { + 'pre-soaking': [], + 'air-drying': [], + 'cracking': [], + 'shelling': [] + } + + allDrafts.forEach(draft => { + groupedDrafts[draft.phase_name].push(draft) + }) + + setPhaseDrafts(groupedDrafts) + } catch (err: any) { + setError(err.message || 'Failed to load phase drafts') + console.error('Load phase drafts error:', err) + } finally { + setLoading(false) + } + } + + const getPhaseStatus = (phase: ExperimentPhase) => { + const drafts = phaseDrafts[phase] + if (drafts.length === 0) return 'empty' + + const hasSubmitted = drafts.some(d => d.status === 'submitted') + const hasDraft = drafts.some(d => d.status === 'draft') + const hasWithdrawn = drafts.some(d => d.status === 'withdrawn') + + if (hasSubmitted) return 'submitted' + if (hasDraft) return 'draft' + if (hasWithdrawn) return 'withdrawn' + return 'empty' + } + + const getStatusBadge = (status: string) => { + switch (status) { + case 'submitted': + return Submitted + case 'draft': + return Draft + case 'withdrawn': + return Withdrawn + case 'empty': + return No Data + default: + return null + } + } + + const getDraftCount = (phase: ExperimentPhase) => { + return phaseDrafts[phase].length + } + + if (loading) { + return ( +
+
+
+

Loading phases...

+
+
+ ) + } + + if (error) { + return ( +
+
{error}
+
+ ) + } + + return ( +
+
+

Select Phase

+

+ Choose a phase to enter or view data. Each phase can have multiple drafts. +

+ {repetition.is_locked && ( +
+
+ 🔒 This repetition is locked by an admin +
+

+ You can view existing data but cannot create new drafts or modify existing ones. +

+
+ )} +
+ +
+ {phases.map((phase) => { + const status = getPhaseStatus(phase.name) + const draftCount = getDraftCount(phase.name) + + return ( +
onPhaseSelect(phase.name)} + className="bg-white rounded-lg shadow-md border border-gray-200 p-6 cursor-pointer hover:shadow-lg hover:border-blue-300 transition-all duration-200" + > +
+
+
+ {phase.icon} +
+
+

{phase.title}

+

{phase.description}

+
+
+ {getStatusBadge(status)} +
+ +
+ + {draftCount === 0 ? 'No drafts' : `${draftCount} draft${draftCount === 1 ? '' : 's'}`} + + + + +
+ + {draftCount > 0 && ( +
+
+ {phaseDrafts[phase.name].slice(0, 3).map((draft, index) => ( + + {draft.draft_name || `Draft ${index + 1}`} + + ))} + {draftCount > 3 && ( + + +{draftCount - 3} more + + )} +
+
+ )} +
+ ) + })} +
+
+ ) +} diff --git a/src/components/RepetitionScheduleModal.tsx b/src/components/RepetitionScheduleModal.tsx new file mode 100644 index 0000000..9c7848d --- /dev/null +++ b/src/components/RepetitionScheduleModal.tsx @@ -0,0 +1,208 @@ +import { useState } from 'react' +import { repetitionManagement } from '../lib/supabase' +import type { Experiment, ExperimentRepetition } from '../lib/supabase' + +interface RepetitionScheduleModalProps { + experiment: Experiment + repetition: ExperimentRepetition + onClose: () => void + onScheduleUpdated: (updatedRepetition: ExperimentRepetition) => void +} + +export function RepetitionScheduleModal({ experiment, repetition, onClose, onScheduleUpdated }: RepetitionScheduleModalProps) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + // Initialize with existing scheduled date or current date/time + const getInitialDateTime = () => { + if (repetition.scheduled_date) { + const date = new Date(repetition.scheduled_date) + return { + date: date.toISOString().split('T')[0], + time: date.toTimeString().slice(0, 5) + } + } + + const now = new Date() + // Set to next hour by default + now.setHours(now.getHours() + 1, 0, 0, 0) + return { + date: now.toISOString().split('T')[0], + time: now.toTimeString().slice(0, 5) + } + } + + const [dateTime, setDateTime] = useState(getInitialDateTime()) + const isScheduled = repetition.scheduled_date && repetition.schedule_status === 'scheduled' + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + setLoading(true) + + try { + // Validate date/time + const selectedDateTime = new Date(`${dateTime.date}T${dateTime.time}`) + const now = new Date() + + if (selectedDateTime <= now) { + setError('Scheduled date and time must be in the future') + setLoading(false) + return + } + + // Schedule the repetition + const updatedRepetition = await repetitionManagement.scheduleRepetition( + repetition.id, + selectedDateTime.toISOString() + ) + + onScheduleUpdated(updatedRepetition) + onClose() + } catch (err: any) { + setError(err.message || 'Failed to schedule repetition') + console.error('Schedule repetition error:', err) + } finally { + setLoading(false) + } + } + + const handleRemoveSchedule = async () => { + if (!confirm('Are you sure you want to remove the schedule for this repetition?')) { + return + } + + setError(null) + setLoading(true) + + try { + const updatedRepetition = await repetitionManagement.removeRepetitionSchedule(repetition.id) + onScheduleUpdated(updatedRepetition) + onClose() + } catch (err: any) { + setError(err.message || 'Failed to remove schedule') + console.error('Remove schedule error:', err) + } finally { + setLoading(false) + } + } + + const handleCancel = () => { + onClose() + } + + return ( +
+
+ {/* Header */} +
+

+ Schedule Repetition +

+ +
+ +
+ {/* Experiment and Repetition Info */} +
+

+ Experiment #{experiment.experiment_number} - Repetition #{repetition.repetition_number} +

+

+ {experiment.reps_required} reps required • {experiment.soaking_duration_hr}h soaking +

+
+ + {/* Error Message */} + {error && ( +
+
{error}
+
+ )} + + {/* Current Schedule (if exists) */} + {isScheduled && ( +
+
Currently Scheduled
+

+ {new Date(repetition.scheduled_date!).toLocaleString()} +

+
+ )} + + {/* Schedule Form */} +
+
+ + setDateTime({ ...dateTime, date: e.target.value })} + className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm" + required + /> +
+ +
+ + setDateTime({ ...dateTime, time: e.target.value })} + className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm" + required + /> +
+ + {/* Action Buttons */} +
+
+ {isScheduled && ( + + )} +
+ +
+ + +
+
+
+
+
+
+ ) +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index cf002cb..c3edffb 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -67,6 +67,15 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) { ), requiredRoles: ['admin', 'conductor', 'data recorder'] + }, + { + id: 'vision-system', + name: 'Vision System', + icon: ( + + + + ), } ] @@ -82,7 +91,7 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
{!isCollapsed && (
-

RBAC System

+

Pecan Experiments

Admin Dashboard

)} diff --git a/src/components/TopNavbar.tsx b/src/components/TopNavbar.tsx index 1fe5e4b..fb68153 100644 --- a/src/components/TopNavbar.tsx +++ b/src/components/TopNavbar.tsx @@ -22,6 +22,8 @@ export function TopNavbar({ user, onLogout, currentView = 'dashboard' }: TopNavb return 'Analytics' case 'data-entry': return 'Data Entry' + case 'vision-system': + return 'Vision System' default: return 'Dashboard' } diff --git a/src/components/VisionSystem.tsx b/src/components/VisionSystem.tsx new file mode 100644 index 0000000..78aefb0 --- /dev/null +++ b/src/components/VisionSystem.tsx @@ -0,0 +1,735 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { + visionApi, + type SystemStatus, + type CameraStatus, + type MachineStatus, + type StorageStats, + type RecordingInfo, + type MqttStatus, + type MqttEventsResponse, + type MqttEvent, + formatBytes, + formatDuration, + formatUptime +} from '../lib/visionApi' + +export function VisionSystem() { + const [systemStatus, setSystemStatus] = useState(null) + const [storageStats, setStorageStats] = useState(null) + const [recordings, setRecordings] = useState>({}) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [refreshing, setRefreshing] = useState(false) + const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true) + const [refreshInterval, setRefreshInterval] = useState(5000) // 5 seconds default + const [lastUpdateTime, setLastUpdateTime] = useState(null) + const [mqttStatus, setMqttStatus] = useState(null) + const [mqttEvents, setMqttEvents] = useState([]) + + const intervalRef = useRef(null) + + const clearAutoRefresh = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + }, []) + + const startAutoRefresh = useCallback(() => { + clearAutoRefresh() + if (autoRefreshEnabled && refreshInterval > 0) { + intervalRef.current = setInterval(fetchData, refreshInterval) + } + }, [autoRefreshEnabled, refreshInterval]) + + useEffect(() => { + fetchData() + startAutoRefresh() + return clearAutoRefresh + }, [startAutoRefresh]) + + useEffect(() => { + startAutoRefresh() + }, [autoRefreshEnabled, refreshInterval, startAutoRefresh]) + + const fetchData = useCallback(async (showRefreshIndicator = true) => { + try { + setError(null) + if (!systemStatus) { + setLoading(true) + } else if (showRefreshIndicator) { + setRefreshing(true) + } + + const [statusData, storageData, recordingsData, mqttStatusData, mqttEventsData] = await Promise.all([ + visionApi.getSystemStatus(), + visionApi.getStorageStats(), + visionApi.getRecordings(), + visionApi.getMqttStatus().catch(err => { + console.warn('Failed to fetch MQTT status:', err) + return null + }), + visionApi.getMqttEvents(10).catch(err => { + console.warn('Failed to fetch MQTT events:', err) + return { events: [], total_events: 0, last_updated: '' } + }) + ]) + + // If cameras don't have device_info, try to fetch individual camera status + if (statusData.cameras) { + const camerasNeedingInfo = Object.entries(statusData.cameras) + .filter(([_, camera]) => !camera.device_info?.friendly_name) + .map(([cameraName, _]) => cameraName) + + if (camerasNeedingInfo.length > 0) { + console.log('Fetching individual camera info for:', camerasNeedingInfo) + try { + const individualCameraData = await Promise.all( + camerasNeedingInfo.map(cameraName => + visionApi.getCameraStatus(cameraName).catch(err => { + console.warn(`Failed to get individual status for ${cameraName}:`, err) + return null + }) + ) + ) + + // Merge the individual camera data back into statusData + camerasNeedingInfo.forEach((cameraName, index) => { + const individualData = individualCameraData[index] + if (individualData && individualData.device_info) { + statusData.cameras[cameraName] = { + ...statusData.cameras[cameraName], + device_info: individualData.device_info + } + } + }) + } catch (err) { + console.warn('Failed to fetch individual camera data:', err) + } + } + } + + // Only update state if data has actually changed to prevent unnecessary re-renders + setSystemStatus(prevStatus => { + if (JSON.stringify(prevStatus) !== JSON.stringify(statusData)) { + return statusData + } + return prevStatus + }) + + setStorageStats(prevStats => { + if (JSON.stringify(prevStats) !== JSON.stringify(storageData)) { + return storageData + } + return prevStats + }) + + setRecordings(prevRecordings => { + if (JSON.stringify(prevRecordings) !== JSON.stringify(recordingsData)) { + return recordingsData + } + return prevRecordings + }) + + setLastUpdateTime(new Date()) + + // Update MQTT status and events + if (mqttStatusData) { + setMqttStatus(mqttStatusData) + } + + if (mqttEventsData && mqttEventsData.events) { + setMqttEvents(mqttEventsData.events) + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch vision system data') + console.error('Vision system fetch error:', err) + // Don't disable auto-refresh on errors, just log them + } finally { + setLoading(false) + setRefreshing(false) + } + }, [systemStatus]) + + const getStatusColor = (status: string, isRecording: boolean = false) => { + // If camera is recording, always show red regardless of status + if (isRecording) { + return 'text-red-600 bg-red-100' + } + + switch (status.toLowerCase()) { + case 'available': + case 'connected': + case 'healthy': + case 'on': + return 'text-green-600 bg-green-100' + case 'disconnected': + case 'off': + case 'failed': + return 'text-red-600 bg-red-100' + case 'error': + case 'warning': + case 'degraded': + return 'text-yellow-600 bg-yellow-100' + default: + return 'text-yellow-600 bg-yellow-100' + } + } + + const getMachineStateColor = (state: string) => { + switch (state.toLowerCase()) { + case 'on': + case 'running': + return 'text-green-600 bg-green-100' + case 'off': + case 'stopped': + return 'text-gray-600 bg-gray-100' + default: + return 'text-yellow-600 bg-yellow-100' + } + } + + if (loading) { + return ( +
+
+
+
+

Loading vision system data...

+
+
+
+ ) + } + + if (error) { + return ( +
+
+
+
+ + + +
+
+

Error loading vision system

+
+

{error}

+
+
+ +
+
+
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

Vision System

+

Monitor cameras, machines, and recording status

+ {lastUpdateTime && ( +

+ Last updated: {lastUpdateTime.toLocaleTimeString()} + {autoRefreshEnabled && !refreshing && ( + + Auto-refresh: {refreshInterval / 1000}s + + )} +

+ )} +
+
+ {/* Auto-refresh controls */} +
+ + {autoRefreshEnabled && ( + + )} +
+ + {/* Refresh indicator and button */} +
+ {refreshing && ( +
+ )} + +
+
+
+ + {/* System Overview */} + {systemStatus && ( +
+
+
+
+
+
+ {systemStatus.system_started ? 'Online' : 'Offline'} +
+
+
+
+
System Status
+
+ Uptime: {formatUptime(systemStatus.uptime_seconds)} +
+
+
+
+ +
+
+
+
+
+
+ {systemStatus.mqtt_connected ? 'Connected' : 'Disconnected'} +
+
+ {systemStatus.mqtt_connected && ( +
+
+ Live +
+ )} +
+ {mqttStatus && ( +
+
{mqttStatus.message_count} messages
+
{mqttStatus.error_count} errors
+
+ )} +
+
+
MQTT
+
+ {mqttStatus ? ( +
+
Broker: {mqttStatus.broker_host}:{mqttStatus.broker_port}
+
Last message: {new Date(mqttStatus.last_message_time).toLocaleTimeString()}
+
+ ) : ( +
Last message: {new Date(systemStatus.last_mqtt_message).toLocaleTimeString()}
+ )} +
+
+ + {/* MQTT Events History */} + {mqttEvents.length > 0 && ( +
+
+

Recent Events

+ {mqttEvents.length} events +
+
+ {mqttEvents.map((event, index) => ( +
+
+ + {new Date(event.timestamp).toLocaleTimeString().slice(-8, -3)} + + + {event.machine_name.replace('_', ' ')} + + + {event.payload} + +
+ #{event.message_number} +
+ ))} +
+
+ )} +
+
+ +
+
+
+
+
+ {systemStatus.active_recordings} +
+
+
+
+
Active Recordings
+
+ Total: {systemStatus.total_recordings} +
+
+
+
+ +
+
+
+
+
+ {Object.keys(systemStatus.cameras).length} +
+
+
+
+
Cameras
+
+ Machines: {Object.keys(systemStatus.machines).length} +
+
+
+
+
+ )} + + + + {/* Cameras Status */} + {systemStatus && ( +
+
+

Cameras

+

+ Current status of all cameras in the system +

+
+
+
+ {Object.entries(systemStatus.cameras).map(([cameraName, camera]) => { + // Debug logging to see what data we're getting + console.log(`Camera ${cameraName} data:`, JSON.stringify(camera, null, 2)) + + const friendlyName = camera.device_info?.friendly_name + const hasDeviceInfo = !!camera.device_info + const hasSerial = !!camera.device_info?.serial_number + + return ( +
+
+
+

+ {friendlyName ? ( +
+
{friendlyName}
+
({cameraName})
+
+ ) : ( +
+
{cameraName}
+
+ {hasDeviceInfo ? 'Device info available but no friendly name' : 'No device info available'} +
+
+ )} +

+
+ + {camera.is_recording ? 'Recording' : camera.status} + +
+ +
+
+ Recording: + + {camera.is_recording ? 'Yes' : 'No'} + +
+ + {camera.device_info?.serial_number && ( +
+ Serial: + {camera.device_info.serial_number} +
+ )} + + {/* Debug info - remove this after fixing */} +
+
Debug Info:
+
+
Has device_info: {hasDeviceInfo ? 'Yes' : 'No'}
+
Has friendly_name: {friendlyName ? 'Yes' : 'No'}
+
Has serial: {hasSerial ? 'Yes' : 'No'}
+
Last error: {camera.last_error || 'None'}
+ {camera.device_info && ( +
+
Raw device_info: {JSON.stringify(camera.device_info)}
+
+ )} +
+
+ +
+ Last checked: + {new Date(camera.last_checked).toLocaleTimeString()} +
+ + {camera.current_recording_file && ( +
+ Recording file: + {camera.current_recording_file} +
+ )} +
+
+ ) + })} +
+
+
+ )} + + {/* Machines Status */} + {systemStatus && Object.keys(systemStatus.machines).length > 0 && ( +
+
+

Machines

+

+ Current status of all machines in the system +

+
+
+
+ {Object.entries(systemStatus.machines).map(([machineName, machine]) => ( +
+
+

+ {machineName.replace(/_/g, ' ')} +

+ + {machine.state} + +
+ +
+
+ Last updated: + {new Date(machine.last_updated).toLocaleTimeString()} +
+ + {machine.last_message && ( +
+ Last message: + {machine.last_message} +
+ )} + + {machine.mqtt_topic && ( +
+ MQTT topic: + {machine.mqtt_topic} +
+ )} +
+
+ ))} +
+
+
+ )} + + {/* Storage Statistics */} + {storageStats && ( +
+
+

Storage

+

+ Storage usage and file statistics +

+
+
+
+
+
{storageStats.total_files}
+
Total Files
+
+
+
{formatBytes(storageStats.total_size_bytes)}
+
Total Size
+
+
+
{formatBytes(storageStats.disk_usage.free)}
+
Free Space
+
+
+ + {/* Disk Usage Bar */} +
+
+ Disk Usage + {Math.round((storageStats.disk_usage.used / storageStats.disk_usage.total) * 100)}% used +
+
+
+
+
+ {formatBytes(storageStats.disk_usage.used)} used + {formatBytes(storageStats.disk_usage.total)} total +
+
+ + {/* Per-Camera Statistics */} + {Object.keys(storageStats.cameras).length > 0 && ( +
+

Files by Camera

+
+ {Object.entries(storageStats.cameras).map(([cameraName, stats]) => { + // Find the corresponding camera to get friendly name + const camera = systemStatus?.cameras[cameraName] + const displayName = camera?.device_info?.friendly_name || cameraName + + return ( +
+
+ {camera?.device_info?.friendly_name ? ( + <> + {displayName} + ({cameraName}) + + ) : ( + cameraName + )} +
+
+
+ Files: + {stats.file_count} +
+
+ Size: + {formatBytes(stats.total_size_bytes)} +
+
+
+ ) + })} +
+
+ )} +
+
+ )} + + {/* Recent Recordings */} + {Object.keys(recordings).length > 0 && ( +
+
+

Recent Recordings

+

+ Latest recording sessions +

+
+
+
+ + + + + + + + + + + + + {Object.entries(recordings).slice(0, 10).map(([recordingId, recording]) => { + // Find the corresponding camera to get friendly name + const camera = systemStatus?.cameras[recording.camera_name] + const displayName = camera?.device_info?.friendly_name || recording.camera_name + + return ( + + + + + + + + + ) + })} + +
+ Camera + + Filename + + Status + + Duration + + Size + + Started +
+ {camera?.device_info?.friendly_name ? ( +
+
{displayName}
+
({recording.camera_name})
+
+ ) : ( + recording.camera_name + )} +
+ {recording.filename} + + + {recording.state} + + + {recording.duration_seconds ? formatDuration(recording.duration_seconds) : '-'} + + {recording.file_size_bytes ? formatBytes(recording.file_size_bytes) : '-'} + + {new Date(recording.start_time).toLocaleString()} +
+
+
+
+ )} +
+ ) +} diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 7d943d1..b772f1c 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -38,15 +38,15 @@ export interface Experiment { throughput_rate_pecans_sec: number crush_amount_in: number entry_exit_height_diff_in: number - schedule_status: ScheduleStatus results_status: ResultsStatus completion_status: boolean - scheduled_date?: string | null created_at: string updated_at: string created_by: string } + + export interface CreateExperimentRequest { experiment_number: number reps_required: number @@ -56,10 +56,8 @@ export interface CreateExperimentRequest { throughput_rate_pecans_sec: number crush_amount_in: number entry_exit_height_diff_in: number - schedule_status?: ScheduleStatus results_status?: ResultsStatus completion_status?: boolean - scheduled_date?: string | null } export interface UpdateExperimentRequest { @@ -71,25 +69,54 @@ export interface UpdateExperimentRequest { throughput_rate_pecans_sec?: number crush_amount_in?: number entry_exit_height_diff_in?: number - schedule_status?: ScheduleStatus results_status?: ResultsStatus completion_status?: boolean +} + +export interface CreateRepetitionRequest { + experiment_id: string + repetition_number: number scheduled_date?: string | null + schedule_status?: ScheduleStatus +} + +export interface UpdateRepetitionRequest { + scheduled_date?: string | null + schedule_status?: ScheduleStatus + completion_status?: boolean } // Data Entry System Interfaces -export type DataEntryStatus = 'draft' | 'submitted' +export type PhaseDraftStatus = 'draft' | 'submitted' | 'withdrawn' export type ExperimentPhase = 'pre-soaking' | 'air-drying' | 'cracking' | 'shelling' -export interface ExperimentDataEntry { +export interface ExperimentPhaseDraft { id: string experiment_id: string + repetition_id: string user_id: string - status: DataEntryStatus - entry_name?: string | null + phase_name: ExperimentPhase + status: PhaseDraftStatus + draft_name?: string | null created_at: string updated_at: string submitted_at?: string | null + withdrawn_at?: string | null +} + +export interface ExperimentRepetition { + id: string + experiment_id: string + repetition_number: number + scheduled_date?: string | null + schedule_status: ScheduleStatus + completion_status: boolean + is_locked: boolean + locked_at?: string | null + locked_by?: string | null + created_at: string + updated_at: string + created_by: string } export interface PecanDiameterMeasurement { @@ -102,7 +129,7 @@ export interface PecanDiameterMeasurement { export interface ExperimentPhaseData { id: string - data_entry_id: string + phase_draft_id: string phase_name: ExperimentPhase // Pre-soaking phase @@ -141,15 +168,17 @@ export interface ExperimentPhaseData { diameter_measurements?: PecanDiameterMeasurement[] } -export interface CreateDataEntryRequest { +export interface CreatePhaseDraftRequest { experiment_id: string - entry_name?: string - status?: DataEntryStatus + repetition_id: string + phase_name: ExperimentPhase + draft_name?: string + status?: PhaseDraftStatus } -export interface UpdateDataEntryRequest { - entry_name?: string - status?: DataEntryStatus +export interface UpdatePhaseDraftRequest { + draft_name?: string + status?: PhaseDraftStatus } export interface CreatePhaseDataRequest { @@ -440,25 +469,7 @@ export const experimentManagement = { return data }, - // Schedule an experiment - async scheduleExperiment(id: string, scheduledDate: string): Promise { - const updates: UpdateExperimentRequest = { - scheduled_date: scheduledDate, - schedule_status: 'scheduled' - } - return this.updateExperiment(id, updates) - }, - - // Remove experiment schedule - async removeExperimentSchedule(id: string): Promise { - const updates: UpdateExperimentRequest = { - scheduled_date: null, - schedule_status: 'pending schedule' - } - - return this.updateExperiment(id, updates) - }, // Check if experiment number is unique async isExperimentNumberUnique(experimentNumber: number, excludeId?: string): Promise { @@ -478,45 +489,237 @@ export const experimentManagement = { } } -// Data Entry Management -export const dataEntryManagement = { - // Get all data entries for an experiment - async getDataEntriesForExperiment(experimentId: string): Promise { +// Experiment Repetitions Management +export const repetitionManagement = { + // Get all repetitions for an experiment + async getExperimentRepetitions(experimentId: string): Promise { const { data, error } = await supabase - .from('experiment_data_entries') + .from('experiment_repetitions') .select('*') .eq('experiment_id', experimentId) + .order('repetition_number', { ascending: true }) + + if (error) throw error + return data + }, + + // Create a new repetition + async createRepetition(repetitionData: CreateRepetitionRequest): Promise { + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) throw new Error('User not authenticated') + + const { data, error } = await supabase + .from('experiment_repetitions') + .insert({ + ...repetitionData, + created_by: user.id + }) + .select() + .single() + + if (error) throw error + return data + }, + + // Update a repetition + async updateRepetition(id: string, updates: UpdateRepetitionRequest): Promise { + const { data, error } = await supabase + .from('experiment_repetitions') + .update(updates) + .eq('id', id) + .select() + .single() + + if (error) throw error + return data + }, + + // Schedule a repetition + async scheduleRepetition(id: string, scheduledDate: string): Promise { + const updates: UpdateRepetitionRequest = { + scheduled_date: scheduledDate, + schedule_status: 'scheduled' + } + + return this.updateRepetition(id, updates) + }, + + // Remove repetition schedule + async removeRepetitionSchedule(id: string): Promise { + const updates: UpdateRepetitionRequest = { + scheduled_date: null, + schedule_status: 'pending schedule' + } + + return this.updateRepetition(id, updates) + }, + + // Delete a repetition + async deleteRepetition(id: string): Promise { + const { error } = await supabase + .from('experiment_repetitions') + .delete() + .eq('id', id) + + if (error) throw error + }, + + // Get repetitions by status + async getRepetitionsByStatus(scheduleStatus?: ScheduleStatus): Promise { + let query = supabase.from('experiment_repetitions').select('*') + + if (scheduleStatus) { + query = query.eq('schedule_status', scheduleStatus) + } + + const { data, error } = await query.order('created_at', { ascending: false }) + + if (error) throw error + return data + }, + + // Get repetitions with experiment details + async getRepetitionsWithExperiments(): Promise<(ExperimentRepetition & { experiment: Experiment })[]> { + const { data, error } = await supabase + .from('experiment_repetitions') + .select(` + *, + experiment:experiments(*) + `) .order('created_at', { ascending: false }) if (error) throw error return data }, - // Get user's data entries for an experiment - async getUserDataEntriesForExperiment(experimentId: string, userId?: string): Promise { + // Create all repetitions for an experiment + async createAllRepetitions(experimentId: string): Promise { + // First get the experiment to know how many reps are required + const { data: experiment, error: expError } = await supabase + .from('experiments') + .select('reps_required') + .eq('id', experimentId) + .single() + + if (expError) throw expError + + // Create repetitions for each required rep + const repetitions: CreateRepetitionRequest[] = [] + for (let i = 1; i <= experiment.reps_required; i++) { + repetitions.push({ + experiment_id: experimentId, + repetition_number: i, + schedule_status: 'pending schedule' + }) + } + const { data: { user }, error: authError } = await supabase.auth.getUser() if (authError || !user) throw new Error('User not authenticated') - const targetUserId = userId || user.id + const { data, error } = await supabase + .from('experiment_repetitions') + .insert(repetitions.map(rep => ({ + ...rep, + created_by: user.id + }))) + .select() + + if (error) throw error + return data + }, + + // Lock a repetition (admin only) + async lockRepetition(repetitionId: string): Promise { + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) throw new Error('User not authenticated') const { data, error } = await supabase - .from('experiment_data_entries') + .from('experiment_repetitions') + .update({ + is_locked: true, + locked_at: new Date().toISOString(), + locked_by: user.id + }) + .eq('id', repetitionId) + .select() + .single() + + if (error) throw error + return data + }, + + // Unlock a repetition (admin only) + async unlockRepetition(repetitionId: string): Promise { + const { data, error } = await supabase + .from('experiment_repetitions') + .update({ + is_locked: false, + locked_at: null, + locked_by: null + }) + .eq('id', repetitionId) + .select() + .single() + + if (error) throw error + return data + } +} + +// Phase Draft Management +export const phaseDraftManagement = { + // Get all phase drafts for a repetition + async getPhaseDraftsForRepetition(repetitionId: string): Promise { + const { data, error } = await supabase + .from('experiment_phase_drafts') .select('*') - .eq('experiment_id', experimentId) - .eq('user_id', targetUserId) + .eq('repetition_id', repetitionId) .order('created_at', { ascending: false }) if (error) throw error return data }, - // Create a new data entry - async createDataEntry(request: CreateDataEntryRequest): Promise { + // Get user's phase drafts for a repetition + async getUserPhaseDraftsForRepetition(repetitionId: string): Promise { const { data: { user }, error: authError } = await supabase.auth.getUser() if (authError || !user) throw new Error('User not authenticated') const { data, error } = await supabase - .from('experiment_data_entries') + .from('experiment_phase_drafts') + .select('*') + .eq('repetition_id', repetitionId) + .eq('user_id', user.id) + .order('created_at', { ascending: false }) + + if (error) throw error + return data + }, + + // Get user's phase drafts for a specific phase and repetition + async getUserPhaseDraftsForPhase(repetitionId: string, phase: ExperimentPhase): Promise { + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) throw new Error('User not authenticated') + + const { data, error } = await supabase + .from('experiment_phase_drafts') + .select('*') + .eq('repetition_id', repetitionId) + .eq('user_id', user.id) + .eq('phase_name', phase) + .order('created_at', { ascending: false }) + + if (error) throw error + return data + }, + + // Create a new phase draft + async createPhaseDraft(request: CreatePhaseDraftRequest): Promise { + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) throw new Error('User not authenticated') + + const { data, error } = await supabase + .from('experiment_phase_drafts') .insert({ ...request, user_id: user.id @@ -528,10 +731,10 @@ export const dataEntryManagement = { return data }, - // Update a data entry - async updateDataEntry(id: string, updates: UpdateDataEntryRequest): Promise { + // Update a phase draft + async updatePhaseDraft(id: string, updates: UpdatePhaseDraftRequest): Promise { const { data, error } = await supabase - .from('experiment_data_entries') + .from('experiment_phase_drafts') .update(updates) .eq('id', id) .select() @@ -541,65 +744,53 @@ export const dataEntryManagement = { return data }, - // Delete a data entry (only drafts) - async deleteDataEntry(id: string): Promise { + // Delete a phase draft (only drafts) + async deletePhaseDraft(id: string): Promise { const { error } = await supabase - .from('experiment_data_entries') + .from('experiment_phase_drafts') .delete() .eq('id', id) if (error) throw error }, - // Submit a data entry (change status from draft to submitted) - async submitDataEntry(id: string): Promise { - return this.updateDataEntry(id, { status: 'submitted' }) + // Submit a phase draft (change status from draft to submitted) + async submitPhaseDraft(id: string): Promise { + return this.updatePhaseDraft(id, { status: 'submitted' }) }, - // Get phase data for a data entry - async getPhaseDataForEntry(dataEntryId: string): Promise { + // Withdraw a phase draft (change status from submitted to withdrawn) + async withdrawPhaseDraft(id: string): Promise { + return this.updatePhaseDraft(id, { status: 'withdrawn' }) + }, + + // Get phase data for a phase draft + async getPhaseDataForDraft(phaseDraftId: string): Promise { const { data, error } = await supabase .from('experiment_phase_data') .select(` *, diameter_measurements:pecan_diameter_measurements(*) `) - .eq('data_entry_id', dataEntryId) - .order('phase_name') - - if (error) throw error - return data - }, - - // Get specific phase data - async getPhaseData(dataEntryId: string, phaseName: ExperimentPhase): Promise { - const { data, error } = await supabase - .from('experiment_phase_data') - .select(` - *, - diameter_measurements:pecan_diameter_measurements(*) - `) - .eq('data_entry_id', dataEntryId) - .eq('phase_name', phaseName) + .eq('phase_draft_id', phaseDraftId) .single() if (error) { - if (error.code === 'PGRST116') return null // Not found + if (error.code === 'PGRST116') return null // No rows found throw error } return data }, - // Create or update phase data - async upsertPhaseData(dataEntryId: string, phaseName: ExperimentPhase, phaseData: Partial): Promise { + // Create or update phase data for a draft + async upsertPhaseData(phaseDraftId: string, phaseData: Partial): Promise { const { data, error } = await supabase .from('experiment_phase_data') .upsert({ - data_entry_id: dataEntryId, - phase_name: phaseName, + phase_draft_id: phaseDraftId, ...phaseData }, { - onConflict: 'data_entry_id,phase_name' + onConflict: 'phase_draft_id,phase_name' }) .select() .single() @@ -641,9 +832,9 @@ export const dataEntryManagement = { }, // Auto-save draft data (for periodic saves) - async autoSaveDraft(dataEntryId: string, phaseName: ExperimentPhase, phaseData: Partial): Promise { + async autoSaveDraft(phaseDraftId: string, phaseData: Partial): Promise { try { - await this.upsertPhaseData(dataEntryId, phaseName, phaseData) + await this.upsertPhaseData(phaseDraftId, phaseData) } catch (error) { console.warn('Auto-save failed:', error) // Don't throw error for auto-save failures diff --git a/src/lib/visionApi.ts b/src/lib/visionApi.ts new file mode 100644 index 0000000..8a08a07 --- /dev/null +++ b/src/lib/visionApi.ts @@ -0,0 +1,336 @@ +// Vision System API Client +// Base URL for the vision system API +const VISION_API_BASE_URL = 'http://localhost:8000' + +// Types based on the API documentation +export interface SystemStatus { + system_started: boolean + mqtt_connected: boolean + last_mqtt_message: string + machines: Record + cameras: Record + active_recordings: number + total_recordings: number + uptime_seconds: number +} + +export interface MachineStatus { + name: string + state: string + last_updated: string + last_message?: string + mqtt_topic?: string +} + +export interface CameraStatus { + name: string + status: string + is_recording: boolean + last_checked: string + last_error: string | null + device_info?: { + friendly_name: string + serial_number: string + } + current_recording_file: string | null + recording_start_time: string | null +} + +export interface RecordingInfo { + camera_name: string + filename: string + start_time: string + state: string + end_time?: string + file_size_bytes?: number + frame_count?: number + duration_seconds?: number + error_message?: string | null +} + +export interface StorageStats { + base_path: string + total_files: number + total_size_bytes: number + cameras: Record + disk_usage: { + total: number + used: number + free: number + } +} + +export interface RecordingFile { + filename: string + camera_name: string + file_size_bytes: number + created_date: string + duration_seconds?: number +} + +export interface StartRecordingRequest { + filename?: string + exposure_ms?: number + gain?: number + fps?: number +} + +export interface StartRecordingResponse { + success: boolean + message: string + filename: string +} + +export interface StopRecordingResponse { + success: boolean + message: string + duration_seconds: number +} + +export interface CameraTestResponse { + success: boolean + message: string + camera_name: string + timestamp: string +} + +export interface CameraRecoveryResponse { + success: boolean + message: string + camera_name: string + operation: string + timestamp: string +} + +export interface MqttMessage { + timestamp: string + topic: string + message: string + source: string +} + +export interface MqttStatus { + connected: boolean + broker_host: string + broker_port: number + subscribed_topics: string[] + last_message_time: string + message_count: number + error_count: number + uptime_seconds: number +} + +export interface MqttEvent { + machine_name: string + topic: string + payload: string + normalized_state: string + timestamp: string + message_number: number +} + +export interface MqttEventsResponse { + events: MqttEvent[] + total_events: number + last_updated: string +} + +export interface FileListRequest { + camera_name?: string + start_date?: string + end_date?: string + limit?: number +} + +export interface FileListResponse { + files: RecordingFile[] + total_count: number +} + +export interface CleanupRequest { + max_age_days?: number +} + +export interface CleanupResponse { + files_removed: number + bytes_freed: number + errors: string[] +} + +// API Client Class +class VisionApiClient { + private baseUrl: string + + constructor(baseUrl: string = VISION_API_BASE_URL) { + this.baseUrl = baseUrl + } + + private async request(endpoint: string, options: RequestInit = {}): Promise { + const url = `${this.baseUrl}${endpoint}` + + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + ...options, + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`) + } + + return response.json() + } + + // System endpoints + async getHealth(): Promise<{ status: string; timestamp: string }> { + return this.request('/health') + } + + async getSystemStatus(): Promise { + return this.request('/system/status') + } + + // Machine endpoints + async getMachines(): Promise> { + return this.request('/machines') + } + + // MQTT endpoints + async getMqttStatus(): Promise { + return this.request('/mqtt/status') + } + + async getMqttEvents(limit: number = 10): Promise { + return this.request(`/mqtt/events?limit=${limit}`) + } + + // Camera endpoints + async getCameras(): Promise> { + return this.request('/cameras') + } + + async getCameraStatus(cameraName: string): Promise { + return this.request(`/cameras/${cameraName}/status`) + } + + // Recording control + async startRecording(cameraName: string, params: StartRecordingRequest = {}): Promise { + return this.request(`/cameras/${cameraName}/start-recording`, { + method: 'POST', + body: JSON.stringify(params), + }) + } + + async stopRecording(cameraName: string): Promise { + return this.request(`/cameras/${cameraName}/stop-recording`, { + method: 'POST', + }) + } + + // Camera diagnostics + async testCameraConnection(cameraName: string): Promise { + return this.request(`/cameras/${cameraName}/test-connection`, { + method: 'POST', + }) + } + + async reconnectCamera(cameraName: string): Promise { + return this.request(`/cameras/${cameraName}/reconnect`, { + method: 'POST', + }) + } + + async restartCameraGrab(cameraName: string): Promise { + return this.request(`/cameras/${cameraName}/restart-grab`, { + method: 'POST', + }) + } + + async resetCameraTimestamp(cameraName: string): Promise { + return this.request(`/cameras/${cameraName}/reset-timestamp`, { + method: 'POST', + }) + } + + async fullCameraReset(cameraName: string): Promise { + return this.request(`/cameras/${cameraName}/full-reset`, { + method: 'POST', + }) + } + + async reinitializeCamera(cameraName: string): Promise { + return this.request(`/cameras/${cameraName}/reinitialize`, { + method: 'POST', + }) + } + + // Recording sessions + async getRecordings(): Promise> { + return this.request('/recordings') + } + + // Storage endpoints + async getStorageStats(): Promise { + return this.request('/storage/stats') + } + + async getFiles(params: FileListRequest = {}): Promise { + return this.request('/storage/files', { + method: 'POST', + body: JSON.stringify(params), + }) + } + + async cleanupStorage(params: CleanupRequest = {}): Promise { + return this.request('/storage/cleanup', { + method: 'POST', + body: JSON.stringify(params), + }) + } +} + +// Export a singleton instance +export const visionApi = new VisionApiClient() + +// Utility functions +export const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] +} + +export const formatDuration = (seconds: number): string => { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = Math.floor(seconds % 60) + + if (hours > 0) { + return `${hours}h ${minutes}m ${secs}s` + } else if (minutes > 0) { + return `${minutes}m ${secs}s` + } else { + return `${secs}s` + } +} + +export const formatUptime = (seconds: number): string => { + const days = Math.floor(seconds / 86400) + const hours = Math.floor((seconds % 86400) / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + + if (days > 0) { + return `${days}d ${hours}h ${minutes}m` + } else if (hours > 0) { + return `${hours}h ${minutes}m` + } else { + return `${minutes}m` + } +} diff --git a/src/test/visionApi.test.ts b/src/test/visionApi.test.ts new file mode 100644 index 0000000..dce1fa8 --- /dev/null +++ b/src/test/visionApi.test.ts @@ -0,0 +1,51 @@ +// Simple test file to verify vision API client functionality +// This is not a formal test suite, just a manual verification script + +import { visionApi, formatBytes, formatDuration, formatUptime } from '../lib/visionApi' + +// Test utility functions +console.log('Testing utility functions:') +console.log('formatBytes(1024):', formatBytes(1024)) // Should be "1 KB" +console.log('formatBytes(1048576):', formatBytes(1048576)) // Should be "1 MB" +console.log('formatDuration(65):', formatDuration(65)) // Should be "1m 5s" +console.log('formatUptime(3661):', formatUptime(3661)) // Should be "1h 1m" + +// Test API endpoints (these will fail if vision system is not running) +export async function testVisionApi() { + try { + console.log('Testing vision API endpoints...') + + // Test health endpoint + const health = await visionApi.getHealth() + console.log('Health check:', health) + + // Test system status + const status = await visionApi.getSystemStatus() + console.log('System status:', status) + + // Test cameras + const cameras = await visionApi.getCameras() + console.log('Cameras:', cameras) + + // Test machines + const machines = await visionApi.getMachines() + console.log('Machines:', machines) + + // Test storage stats + const storage = await visionApi.getStorageStats() + console.log('Storage stats:', storage) + + // Test recordings + const recordings = await visionApi.getRecordings() + console.log('Recordings:', recordings) + + console.log('All API tests passed!') + return true + } catch (error) { + console.error('API test failed:', error) + return false + } +} + +// Uncomment the line below to run the test when this file is imported +// testVisionApi() diff --git a/supabase/migrations/20250723000001_experiment_data_entry_system.sql b/supabase/migrations/20250723000001_experiment_data_entry_system.sql deleted file mode 100644 index 8a9866c..0000000 --- a/supabase/migrations/20250723000001_experiment_data_entry_system.sql +++ /dev/null @@ -1,263 +0,0 @@ --- Experiment Data Entry System Migration --- Creates tables for collaborative data entry with draft functionality and phase-based organization - --- Create experiment_data_entries table for main data entry records -CREATE TABLE IF NOT EXISTS public.experiment_data_entries ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - experiment_id UUID NOT NULL REFERENCES public.experiments(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES public.user_profiles(id), - status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'submitted')), - entry_name TEXT, -- Optional name for the entry (e.g., "Morning Run", "Batch A") - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - submitted_at TIMESTAMP WITH TIME ZONE, -- When status changed to 'submitted' - - -- Constraint: Only one submitted entry per user per experiment - CONSTRAINT unique_submitted_entry_per_user_experiment - EXCLUDE (experiment_id WITH =, user_id WITH =) - WHERE (status = 'submitted') -); - --- Create experiment_phase_data table for phase-specific measurements -CREATE TABLE IF NOT EXISTS public.experiment_phase_data ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - data_entry_id UUID NOT NULL REFERENCES public.experiment_data_entries(id) ON DELETE CASCADE, - phase_name TEXT NOT NULL CHECK (phase_name IN ('pre-soaking', 'air-drying', 'cracking', 'shelling')), - - -- Pre-soaking phase data - batch_initial_weight_lbs FLOAT CHECK (batch_initial_weight_lbs >= 0), - initial_shell_moisture_pct FLOAT CHECK (initial_shell_moisture_pct >= 0 AND initial_shell_moisture_pct <= 100), - initial_kernel_moisture_pct FLOAT CHECK (initial_kernel_moisture_pct >= 0 AND initial_kernel_moisture_pct <= 100), - soaking_start_time TIMESTAMP WITH TIME ZONE, - - -- Air-drying phase data - airdrying_start_time TIMESTAMP WITH TIME ZONE, - post_soak_weight_lbs FLOAT CHECK (post_soak_weight_lbs >= 0), - post_soak_kernel_moisture_pct FLOAT CHECK (post_soak_kernel_moisture_pct >= 0 AND post_soak_kernel_moisture_pct <= 100), - post_soak_shell_moisture_pct FLOAT CHECK (post_soak_shell_moisture_pct >= 0 AND post_soak_shell_moisture_pct <= 100), - avg_pecan_diameter_in FLOAT CHECK (avg_pecan_diameter_in >= 0), - - -- Cracking phase data - cracking_start_time TIMESTAMP WITH TIME ZONE, - - -- Shelling phase data - shelling_start_time TIMESTAMP WITH TIME ZONE, - bin_1_weight_lbs FLOAT CHECK (bin_1_weight_lbs >= 0), - bin_2_weight_lbs FLOAT CHECK (bin_2_weight_lbs >= 0), - bin_3_weight_lbs FLOAT CHECK (bin_3_weight_lbs >= 0), - discharge_bin_weight_lbs FLOAT CHECK (discharge_bin_weight_lbs >= 0), - bin_1_full_yield_oz FLOAT CHECK (bin_1_full_yield_oz >= 0), - bin_2_full_yield_oz FLOAT CHECK (bin_2_full_yield_oz >= 0), - bin_3_full_yield_oz FLOAT CHECK (bin_3_full_yield_oz >= 0), - bin_1_half_yield_oz FLOAT CHECK (bin_1_half_yield_oz >= 0), - bin_2_half_yield_oz FLOAT CHECK (bin_2_half_yield_oz >= 0), - bin_3_half_yield_oz FLOAT CHECK (bin_3_half_yield_oz >= 0), - - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - - -- Constraint: One record per phase per data entry - CONSTRAINT unique_phase_per_data_entry UNIQUE (data_entry_id, phase_name) -); - --- Create pecan_diameter_measurements table for individual diameter measurements -CREATE TABLE IF NOT EXISTS public.pecan_diameter_measurements ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - phase_data_id UUID NOT NULL REFERENCES public.experiment_phase_data(id) ON DELETE CASCADE, - measurement_number INTEGER NOT NULL CHECK (measurement_number >= 1 AND measurement_number <= 10), - diameter_in FLOAT NOT NULL CHECK (diameter_in >= 0), - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - - -- Constraint: Unique measurement number per phase data - CONSTRAINT unique_measurement_per_phase UNIQUE (phase_data_id, measurement_number) -); - --- Create indexes for better performance -CREATE INDEX IF NOT EXISTS idx_experiment_data_entries_experiment_id ON public.experiment_data_entries(experiment_id); -CREATE INDEX IF NOT EXISTS idx_experiment_data_entries_user_id ON public.experiment_data_entries(user_id); -CREATE INDEX IF NOT EXISTS idx_experiment_data_entries_status ON public.experiment_data_entries(status); -CREATE INDEX IF NOT EXISTS idx_experiment_data_entries_created_at ON public.experiment_data_entries(created_at); - -CREATE INDEX IF NOT EXISTS idx_experiment_phase_data_entry_id ON public.experiment_phase_data(data_entry_id); -CREATE INDEX IF NOT EXISTS idx_experiment_phase_data_phase_name ON public.experiment_phase_data(phase_name); - -CREATE INDEX IF NOT EXISTS idx_pecan_diameter_measurements_phase_data_id ON public.pecan_diameter_measurements(phase_data_id); - --- Create triggers for updated_at -CREATE TRIGGER set_updated_at_experiment_data_entries - BEFORE UPDATE ON public.experiment_data_entries - FOR EACH ROW - EXECUTE FUNCTION public.handle_updated_at(); - -CREATE TRIGGER set_updated_at_experiment_phase_data - BEFORE UPDATE ON public.experiment_phase_data - FOR EACH ROW - EXECUTE FUNCTION public.handle_updated_at(); - --- Create trigger to set submitted_at timestamp -CREATE OR REPLACE FUNCTION public.handle_data_entry_submission() -RETURNS TRIGGER AS $$ -BEGIN - -- Set submitted_at when status changes to 'submitted' - IF NEW.status = 'submitted' AND OLD.status != 'submitted' THEN - NEW.submitted_at = NOW(); - END IF; - - -- Clear submitted_at when status changes from 'submitted' to 'draft' - IF NEW.status = 'draft' AND OLD.status = 'submitted' THEN - NEW.submitted_at = NULL; - END IF; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER set_submitted_at_experiment_data_entries - BEFORE UPDATE ON public.experiment_data_entries - FOR EACH ROW - EXECUTE FUNCTION public.handle_data_entry_submission(); - --- Enable RLS on all tables -ALTER TABLE public.experiment_data_entries ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.experiment_phase_data ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.pecan_diameter_measurements ENABLE ROW LEVEL SECURITY; - --- RLS Policies for experiment_data_entries table - --- Policy: All authenticated users can view all data entries -CREATE POLICY "experiment_data_entries_select_policy" ON public.experiment_data_entries - FOR SELECT - TO authenticated - USING (true); - --- Policy: All authenticated users can insert data entries -CREATE POLICY "experiment_data_entries_insert_policy" ON public.experiment_data_entries - FOR INSERT - TO authenticated - WITH CHECK (user_id = auth.uid()); - --- Policy: Users can only update their own data entries -CREATE POLICY "experiment_data_entries_update_policy" ON public.experiment_data_entries - FOR UPDATE - TO authenticated - USING (user_id = auth.uid()) - WITH CHECK (user_id = auth.uid()); - --- Policy: Users can only delete their own draft entries -CREATE POLICY "experiment_data_entries_delete_policy" ON public.experiment_data_entries - FOR DELETE - TO authenticated - USING (user_id = auth.uid() AND status = 'draft'); - --- RLS Policies for experiment_phase_data table - --- Policy: All authenticated users can view phase data -CREATE POLICY "experiment_phase_data_select_policy" ON public.experiment_phase_data - FOR SELECT - TO authenticated - USING (true); - --- Policy: Users can insert phase data for their own data entries -CREATE POLICY "experiment_phase_data_insert_policy" ON public.experiment_phase_data - FOR INSERT - TO authenticated - WITH CHECK ( - EXISTS ( - SELECT 1 FROM public.experiment_data_entries ede - WHERE ede.id = data_entry_id AND ede.user_id = auth.uid() - ) - ); - --- Policy: Users can update phase data for their own data entries -CREATE POLICY "experiment_phase_data_update_policy" ON public.experiment_phase_data - FOR UPDATE - TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM public.experiment_data_entries ede - WHERE ede.id = data_entry_id AND ede.user_id = auth.uid() - ) - ) - WITH CHECK ( - EXISTS ( - SELECT 1 FROM public.experiment_data_entries ede - WHERE ede.id = data_entry_id AND ede.user_id = auth.uid() - ) - ); - --- Policy: Users can delete phase data for their own draft entries -CREATE POLICY "experiment_phase_data_delete_policy" ON public.experiment_phase_data - FOR DELETE - TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM public.experiment_data_entries ede - WHERE ede.id = data_entry_id AND ede.user_id = auth.uid() AND ede.status = 'draft' - ) - ); - --- RLS Policies for pecan_diameter_measurements table - --- Policy: All authenticated users can view diameter measurements -CREATE POLICY "pecan_diameter_measurements_select_policy" ON public.pecan_diameter_measurements - FOR SELECT - TO authenticated - USING (true); - --- Policy: Users can insert measurements for their own phase data -CREATE POLICY "pecan_diameter_measurements_insert_policy" ON public.pecan_diameter_measurements - FOR INSERT - TO authenticated - WITH CHECK ( - EXISTS ( - SELECT 1 FROM public.experiment_phase_data epd - JOIN public.experiment_data_entries ede ON epd.data_entry_id = ede.id - WHERE epd.id = phase_data_id AND ede.user_id = auth.uid() - ) - ); - --- Policy: Users can update measurements for their own phase data -CREATE POLICY "pecan_diameter_measurements_update_policy" ON public.pecan_diameter_measurements - FOR UPDATE - TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM public.experiment_phase_data epd - JOIN public.experiment_data_entries ede ON epd.data_entry_id = ede.id - WHERE epd.id = phase_data_id AND ede.user_id = auth.uid() - ) - ) - WITH CHECK ( - EXISTS ( - SELECT 1 FROM public.experiment_phase_data epd - JOIN public.experiment_data_entries ede ON epd.data_entry_id = ede.id - WHERE epd.id = phase_data_id AND ede.user_id = auth.uid() - ) - ); - --- Policy: Users can delete measurements for their own draft entries -CREATE POLICY "pecan_diameter_measurements_delete_policy" ON public.pecan_diameter_measurements - FOR DELETE - TO authenticated - USING ( - EXISTS ( - SELECT 1 FROM public.experiment_phase_data epd - JOIN public.experiment_data_entries ede ON epd.data_entry_id = ede.id - WHERE epd.id = phase_data_id AND ede.user_id = auth.uid() AND ede.status = 'draft' - ) - ); - --- Add comments for documentation -COMMENT ON TABLE public.experiment_data_entries IS 'Main data entry records for experiments with draft/submitted status tracking'; -COMMENT ON TABLE public.experiment_phase_data IS 'Phase-specific measurement data for experiments'; -COMMENT ON TABLE public.pecan_diameter_measurements IS 'Individual pecan diameter measurements (up to 10 per phase)'; - -COMMENT ON COLUMN public.experiment_data_entries.status IS 'Entry status: draft (editable) or submitted (final)'; -COMMENT ON COLUMN public.experiment_data_entries.entry_name IS 'Optional descriptive name for the data entry'; -COMMENT ON COLUMN public.experiment_data_entries.submitted_at IS 'Timestamp when entry was submitted (status changed to submitted)'; - -COMMENT ON COLUMN public.experiment_phase_data.phase_name IS 'Experiment phase: pre-soaking, air-drying, cracking, or shelling'; -COMMENT ON COLUMN public.experiment_phase_data.avg_pecan_diameter_in IS 'Average of up to 10 individual diameter measurements'; - -COMMENT ON COLUMN public.pecan_diameter_measurements.measurement_number IS 'Measurement sequence number (1-10)'; -COMMENT ON COLUMN public.pecan_diameter_measurements.diameter_in IS 'Individual pecan diameter measurement in inches'; diff --git a/supabase/migrations/20250724000001_experiment_repetitions_system.sql b/supabase/migrations/20250724000001_experiment_repetitions_system.sql new file mode 100644 index 0000000..dc45274 --- /dev/null +++ b/supabase/migrations/20250724000001_experiment_repetitions_system.sql @@ -0,0 +1,135 @@ +-- Experiment Repetitions System Migration +-- Transforms experiments into blueprints/templates with schedulable repetitions +-- This migration creates the repetitions table and removes scheduling from experiments + +-- Note: Data clearing removed since this runs during fresh database setup + +-- Create experiment_repetitions table +CREATE TABLE IF NOT EXISTS public.experiment_repetitions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + experiment_id UUID NOT NULL REFERENCES public.experiments(id) ON DELETE CASCADE, + repetition_number INTEGER NOT NULL CHECK (repetition_number > 0), + scheduled_date TIMESTAMP WITH TIME ZONE, + schedule_status TEXT NOT NULL DEFAULT 'pending schedule' + CHECK (schedule_status IN ('pending schedule', 'scheduled', 'canceled', 'aborted')), + completion_status BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_by UUID NOT NULL REFERENCES public.user_profiles(id), + + -- Ensure unique repetition numbers per experiment + CONSTRAINT unique_repetition_per_experiment UNIQUE (experiment_id, repetition_number) +); + +-- Create indexes for better performance +CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_experiment_id ON public.experiment_repetitions(experiment_id); +CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_schedule_status ON public.experiment_repetitions(schedule_status); +CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_completion_status ON public.experiment_repetitions(completion_status); +CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_scheduled_date ON public.experiment_repetitions(scheduled_date); +CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_created_by ON public.experiment_repetitions(created_by); +CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_created_at ON public.experiment_repetitions(created_at); + +-- Remove scheduling fields from experiments table since experiments are now blueprints +ALTER TABLE public.experiments DROP COLUMN IF EXISTS scheduled_date; +ALTER TABLE public.experiments DROP COLUMN IF EXISTS schedule_status; + +-- Drop related indexes that are no longer needed +DROP INDEX IF EXISTS idx_experiments_schedule_status; +DROP INDEX IF EXISTS idx_experiments_scheduled_date; + +-- Note: experiment_data_entries table is replaced by experiment_phase_drafts in the new system + +-- Function to validate repetition number doesn't exceed experiment's reps_required +CREATE OR REPLACE FUNCTION validate_repetition_number() +RETURNS TRIGGER AS $$ +DECLARE + max_reps INTEGER; +BEGIN + -- Get the reps_required for this experiment + SELECT reps_required INTO max_reps + FROM public.experiments + WHERE id = NEW.experiment_id; + + -- Check if repetition number exceeds the limit + IF NEW.repetition_number > max_reps THEN + RAISE EXCEPTION 'Repetition number % exceeds maximum allowed repetitions % for experiment', + NEW.repetition_number, max_reps; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Update the trigger function for experiment_repetitions +CREATE OR REPLACE FUNCTION update_experiment_repetitions_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create trigger to validate repetition number +CREATE TRIGGER trigger_validate_repetition_number + BEFORE INSERT OR UPDATE ON public.experiment_repetitions + FOR EACH ROW + EXECUTE FUNCTION validate_repetition_number(); + +-- Create trigger for updated_at on experiment_repetitions +CREATE TRIGGER trigger_experiment_repetitions_updated_at + BEFORE UPDATE ON public.experiment_repetitions + FOR EACH ROW + EXECUTE FUNCTION update_experiment_repetitions_updated_at(); + +-- Enable RLS on experiment_repetitions table +ALTER TABLE public.experiment_repetitions ENABLE ROW LEVEL SECURITY; + +-- Create RLS policies for experiment_repetitions +-- Users can view repetitions for experiments they have access to +CREATE POLICY "Users can view experiment repetitions" ON public.experiment_repetitions + FOR SELECT USING ( + experiment_id IN ( + SELECT id FROM public.experiments + WHERE created_by = auth.uid() + ) + OR public.is_admin() + ); + +-- Users can insert repetitions for experiments they created or if they're admin +CREATE POLICY "Users can create experiment repetitions" ON public.experiment_repetitions + FOR INSERT WITH CHECK ( + experiment_id IN ( + SELECT id FROM public.experiments + WHERE created_by = auth.uid() + ) + OR public.is_admin() + ); + +-- Users can update repetitions for experiments they created or if they're admin +CREATE POLICY "Users can update experiment repetitions" ON public.experiment_repetitions + FOR UPDATE USING ( + experiment_id IN ( + SELECT id FROM public.experiments + WHERE created_by = auth.uid() + ) + OR public.is_admin() + ); + +-- Users can delete repetitions for experiments they created or if they're admin +CREATE POLICY "Users can delete experiment repetitions" ON public.experiment_repetitions + FOR DELETE USING ( + experiment_id IN ( + SELECT id FROM public.experiments + WHERE created_by = auth.uid() + ) + OR public.is_admin() + ); + +-- Add comments for documentation +COMMENT ON TABLE public.experiment_repetitions IS 'Individual repetitions of experiment blueprints that can be scheduled and executed'; +COMMENT ON COLUMN public.experiment_repetitions.experiment_id IS 'Reference to the experiment blueprint'; +COMMENT ON COLUMN public.experiment_repetitions.repetition_number IS 'Sequential number of this repetition (1, 2, 3, etc.)'; +COMMENT ON COLUMN public.experiment_repetitions.scheduled_date IS 'Date and time when this repetition is scheduled to run'; +COMMENT ON COLUMN public.experiment_repetitions.schedule_status IS 'Current scheduling status of this repetition'; +COMMENT ON COLUMN public.experiment_repetitions.completion_status IS 'Whether this repetition has been completed'; +-- Note: experiment_data_entries table is replaced by experiment_phase_drafts in the new system diff --git a/supabase/migrations/20250725000001_experiment_data_entry_system.sql b/supabase/migrations/20250725000001_experiment_data_entry_system.sql new file mode 100644 index 0000000..2114ad3 --- /dev/null +++ b/supabase/migrations/20250725000001_experiment_data_entry_system.sql @@ -0,0 +1,332 @@ +-- Phase-Specific Draft System Migration +-- Creates tables for the new phase-specific draft management system + +-- Create experiment_phase_drafts table for phase-specific draft management +CREATE TABLE IF NOT EXISTS public.experiment_phase_drafts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + experiment_id UUID NOT NULL REFERENCES public.experiments(id) ON DELETE CASCADE, + repetition_id UUID NOT NULL REFERENCES public.experiment_repetitions(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES public.user_profiles(id), + phase_name TEXT NOT NULL CHECK (phase_name IN ('pre-soaking', 'air-drying', 'cracking', 'shelling')), + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'withdrawn')), + draft_name TEXT, -- Optional name for the draft (e.g., "Morning Run", "Batch A") + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + submitted_at TIMESTAMP WITH TIME ZONE, -- When status changed to 'submitted' + withdrawn_at TIMESTAMP WITH TIME ZONE -- When status changed to 'withdrawn' +); + +-- Add repetition locking support +ALTER TABLE public.experiment_repetitions +ADD COLUMN IF NOT EXISTS is_locked BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN IF NOT EXISTS locked_at TIMESTAMP WITH TIME ZONE, +ADD COLUMN IF NOT EXISTS locked_by UUID REFERENCES public.user_profiles(id); + +-- Create experiment_phase_data table for phase-specific measurements +CREATE TABLE IF NOT EXISTS public.experiment_phase_data ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + phase_draft_id UUID NOT NULL REFERENCES public.experiment_phase_drafts(id) ON DELETE CASCADE, + phase_name TEXT NOT NULL CHECK (phase_name IN ('pre-soaking', 'air-drying', 'cracking', 'shelling')), + + -- Pre-soaking phase data + batch_initial_weight_lbs FLOAT CHECK (batch_initial_weight_lbs >= 0), + initial_shell_moisture_pct FLOAT CHECK (initial_shell_moisture_pct >= 0 AND initial_shell_moisture_pct <= 100), + initial_kernel_moisture_pct FLOAT CHECK (initial_kernel_moisture_pct >= 0 AND initial_kernel_moisture_pct <= 100), + soaking_start_time TIMESTAMP WITH TIME ZONE, + + -- Air-drying phase data + airdrying_start_time TIMESTAMP WITH TIME ZONE, + post_soak_weight_lbs FLOAT CHECK (post_soak_weight_lbs >= 0), + post_soak_kernel_moisture_pct FLOAT CHECK (post_soak_kernel_moisture_pct >= 0 AND post_soak_kernel_moisture_pct <= 100), + post_soak_shell_moisture_pct FLOAT CHECK (post_soak_shell_moisture_pct >= 0 AND post_soak_shell_moisture_pct <= 100), + avg_pecan_diameter_in FLOAT CHECK (avg_pecan_diameter_in >= 0), + + -- Cracking phase data + cracking_start_time TIMESTAMP WITH TIME ZONE, + + -- Shelling phase data + shelling_start_time TIMESTAMP WITH TIME ZONE, + bin_1_weight_lbs FLOAT CHECK (bin_1_weight_lbs >= 0), + bin_2_weight_lbs FLOAT CHECK (bin_2_weight_lbs >= 0), + bin_3_weight_lbs FLOAT CHECK (bin_3_weight_lbs >= 0), + discharge_bin_weight_lbs FLOAT CHECK (discharge_bin_weight_lbs >= 0), + bin_1_full_yield_oz FLOAT CHECK (bin_1_full_yield_oz >= 0), + bin_2_full_yield_oz FLOAT CHECK (bin_2_full_yield_oz >= 0), + bin_3_full_yield_oz FLOAT CHECK (bin_3_full_yield_oz >= 0), + bin_1_half_yield_oz FLOAT CHECK (bin_1_half_yield_oz >= 0), + bin_2_half_yield_oz FLOAT CHECK (bin_2_half_yield_oz >= 0), + bin_3_half_yield_oz FLOAT CHECK (bin_3_half_yield_oz >= 0), + + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + -- Constraint: One record per phase draft + CONSTRAINT unique_phase_per_draft UNIQUE (phase_draft_id, phase_name) +); + +-- Create pecan_diameter_measurements table for individual diameter measurements +CREATE TABLE IF NOT EXISTS public.pecan_diameter_measurements ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + phase_data_id UUID NOT NULL REFERENCES public.experiment_phase_data(id) ON DELETE CASCADE, + measurement_number INTEGER NOT NULL CHECK (measurement_number >= 1 AND measurement_number <= 10), + diameter_in FLOAT NOT NULL CHECK (diameter_in >= 0), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + -- Constraint: Unique measurement number per phase data + CONSTRAINT unique_measurement_per_phase UNIQUE (phase_data_id, measurement_number) +); + +-- Create indexes for better performance +CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_experiment_id ON public.experiment_phase_drafts(experiment_id); +CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_repetition_id ON public.experiment_phase_drafts(repetition_id); +CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_user_id ON public.experiment_phase_drafts(user_id); +CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_phase_name ON public.experiment_phase_drafts(phase_name); +CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_status ON public.experiment_phase_drafts(status); +CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_is_locked ON public.experiment_repetitions(is_locked); + +CREATE INDEX IF NOT EXISTS idx_experiment_phase_data_draft_id ON public.experiment_phase_data(phase_draft_id); +CREATE INDEX IF NOT EXISTS idx_experiment_phase_data_phase_name ON public.experiment_phase_data(phase_name); + +CREATE INDEX IF NOT EXISTS idx_pecan_diameter_measurements_phase_data_id ON public.pecan_diameter_measurements(phase_data_id); + +-- Create triggers for updated_at +CREATE TRIGGER set_updated_at_experiment_phase_drafts + BEFORE UPDATE ON public.experiment_phase_drafts + FOR EACH ROW + EXECUTE FUNCTION public.handle_updated_at(); + +CREATE TRIGGER set_updated_at_experiment_phase_data + BEFORE UPDATE ON public.experiment_phase_data + FOR EACH ROW + EXECUTE FUNCTION public.handle_updated_at(); + +-- Create trigger to set submitted_at and withdrawn_at timestamps for phase drafts +CREATE OR REPLACE FUNCTION public.handle_phase_draft_status_change() +RETURNS TRIGGER AS $$ +BEGIN + -- Set submitted_at when status changes to 'submitted' + IF NEW.status = 'submitted' AND OLD.status != 'submitted' THEN + NEW.submitted_at = NOW(); + NEW.withdrawn_at = NULL; + END IF; + + -- Set withdrawn_at when status changes to 'withdrawn' + IF NEW.status = 'withdrawn' AND OLD.status = 'submitted' THEN + NEW.withdrawn_at = NOW(); + END IF; + + -- Clear timestamps when status changes back to 'draft' + IF NEW.status = 'draft' AND OLD.status IN ('submitted', 'withdrawn') THEN + NEW.submitted_at = NULL; + NEW.withdrawn_at = NULL; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_timestamps_experiment_phase_drafts + BEFORE UPDATE ON public.experiment_phase_drafts + FOR EACH ROW + EXECUTE FUNCTION public.handle_phase_draft_status_change(); + +-- Enable RLS on all tables +ALTER TABLE public.experiment_phase_drafts ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.experiment_phase_data ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.pecan_diameter_measurements ENABLE ROW LEVEL SECURITY; + +-- RLS Policies for experiment_phase_drafts table + +-- Policy: All authenticated users can view all phase drafts +CREATE POLICY "experiment_phase_drafts_select_policy" ON public.experiment_phase_drafts + FOR SELECT + TO authenticated + USING (true); + +-- Policy: All authenticated users can insert phase drafts +CREATE POLICY "experiment_phase_drafts_insert_policy" ON public.experiment_phase_drafts + FOR INSERT + TO authenticated + WITH CHECK (user_id = auth.uid()); + +-- Policy: Users can update their own phase drafts if repetition is not locked, admins can update any +CREATE POLICY "experiment_phase_drafts_update_policy" ON public.experiment_phase_drafts + FOR UPDATE + TO authenticated + USING ( + (user_id = auth.uid() AND NOT EXISTS ( + SELECT 1 FROM public.experiment_repetitions + WHERE id = repetition_id AND is_locked = true + )) OR public.is_admin() + ) + WITH CHECK ( + (user_id = auth.uid() AND NOT EXISTS ( + SELECT 1 FROM public.experiment_repetitions + WHERE id = repetition_id AND is_locked = true + )) OR public.is_admin() + ); + +-- Policy: Users can delete their own draft phase drafts if repetition is not locked, admins can delete any +CREATE POLICY "experiment_phase_drafts_delete_policy" ON public.experiment_phase_drafts + FOR DELETE + TO authenticated + USING ( + (user_id = auth.uid() AND status = 'draft' AND NOT EXISTS ( + SELECT 1 FROM public.experiment_repetitions + WHERE id = repetition_id AND is_locked = true + )) OR public.is_admin() + ); + +-- RLS Policies for experiment_phase_data table + +-- Policy: All authenticated users can view phase data +CREATE POLICY "experiment_phase_data_select_policy" ON public.experiment_phase_data + FOR SELECT + TO authenticated + USING (true); + +-- Policy: Users can insert phase data for their own phase drafts +CREATE POLICY "experiment_phase_data_insert_policy" ON public.experiment_phase_data + FOR INSERT + TO authenticated + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.experiment_phase_drafts epd + WHERE epd.id = phase_draft_id AND epd.user_id = auth.uid() + ) + ); + +-- Policy: Users can update phase data for their own phase drafts +CREATE POLICY "experiment_phase_data_update_policy" ON public.experiment_phase_data + FOR UPDATE + TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.experiment_phase_drafts epd + WHERE epd.id = phase_draft_id AND epd.user_id = auth.uid() + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.experiment_phase_drafts epd + WHERE epd.id = phase_draft_id AND epd.user_id = auth.uid() + ) + ); + +-- Policy: Users can delete phase data for their own draft phase drafts +CREATE POLICY "experiment_phase_data_delete_policy" ON public.experiment_phase_data + FOR DELETE + TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.experiment_phase_drafts epd + WHERE epd.id = phase_draft_id AND epd.user_id = auth.uid() AND epd.status = 'draft' + ) + ); + +-- RLS Policies for pecan_diameter_measurements table + +-- Policy: All authenticated users can view diameter measurements +CREATE POLICY "pecan_diameter_measurements_select_policy" ON public.pecan_diameter_measurements + FOR SELECT + TO authenticated + USING (true); + +-- Policy: Users can insert measurements for their own phase data +CREATE POLICY "pecan_diameter_measurements_insert_policy" ON public.pecan_diameter_measurements + FOR INSERT + TO authenticated + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.experiment_phase_data epd + JOIN public.experiment_phase_drafts epdr ON epd.phase_draft_id = epdr.id + WHERE epd.id = phase_data_id AND epdr.user_id = auth.uid() + ) + ); + +-- Policy: Users can update measurements for their own phase data +CREATE POLICY "pecan_diameter_measurements_update_policy" ON public.pecan_diameter_measurements + FOR UPDATE + TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.experiment_phase_data epd + JOIN public.experiment_phase_drafts epdr ON epd.phase_draft_id = epdr.id + WHERE epd.id = phase_data_id AND epdr.user_id = auth.uid() + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.experiment_phase_data epd + JOIN public.experiment_phase_drafts epdr ON epd.phase_draft_id = epdr.id + WHERE epd.id = phase_data_id AND epdr.user_id = auth.uid() + ) + ); + +-- Policy: Users can delete measurements for their own draft phase drafts +CREATE POLICY "pecan_diameter_measurements_delete_policy" ON public.pecan_diameter_measurements + FOR DELETE + TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.experiment_phase_data epd + JOIN public.experiment_phase_drafts epdr ON epd.phase_draft_id = epdr.id + WHERE epd.id = phase_data_id AND epdr.user_id = auth.uid() AND epdr.status = 'draft' + ) + ); + +-- Add indexes for better performance +CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_repetition_id ON public.experiment_phase_drafts(repetition_id); +CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_user_id ON public.experiment_phase_drafts(user_id); +CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_phase_name ON public.experiment_phase_drafts(phase_name); +CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_status ON public.experiment_phase_drafts(status); +CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_is_locked ON public.experiment_repetitions(is_locked); + +-- Add comments for documentation +COMMENT ON TABLE public.experiment_phase_drafts IS 'Phase-specific draft records for experiment data entry with status tracking'; +COMMENT ON TABLE public.experiment_phase_data IS 'Phase-specific measurement data for experiments'; +COMMENT ON TABLE public.pecan_diameter_measurements IS 'Individual pecan diameter measurements (up to 10 per phase)'; + +COMMENT ON COLUMN public.experiment_phase_drafts.status IS 'Draft status: draft (editable), submitted (final), or withdrawn (reverted from submitted)'; +COMMENT ON COLUMN public.experiment_phase_drafts.draft_name IS 'Optional descriptive name for the draft'; +COMMENT ON COLUMN public.experiment_phase_drafts.submitted_at IS 'Timestamp when draft was submitted (status changed to submitted)'; +COMMENT ON COLUMN public.experiment_phase_drafts.withdrawn_at IS 'Timestamp when draft was withdrawn (status changed from submitted to withdrawn)'; + +COMMENT ON COLUMN public.experiment_repetitions.is_locked IS 'Admin lock to prevent draft modifications and withdrawals'; +COMMENT ON COLUMN public.experiment_repetitions.locked_at IS 'Timestamp when repetition was locked'; +COMMENT ON COLUMN public.experiment_repetitions.locked_by IS 'User who locked the repetition'; + +COMMENT ON COLUMN public.experiment_phase_data.phase_name IS 'Experiment phase: pre-soaking, air-drying, cracking, or shelling'; +COMMENT ON COLUMN public.experiment_phase_data.avg_pecan_diameter_in IS 'Average of up to 10 individual diameter measurements'; + +COMMENT ON COLUMN public.pecan_diameter_measurements.measurement_number IS 'Measurement sequence number (1-10)'; +COMMENT ON COLUMN public.pecan_diameter_measurements.diameter_in IS 'Individual pecan diameter measurement in inches'; + +-- Add unique constraint to prevent multiple drafts of same phase by same user for same repetition +ALTER TABLE public.experiment_phase_drafts +ADD CONSTRAINT unique_user_phase_repetition_draft +UNIQUE (user_id, repetition_id, phase_name, status) +DEFERRABLE INITIALLY DEFERRED; + +-- Add function to prevent withdrawal of submitted drafts when repetition is locked +CREATE OR REPLACE FUNCTION public.check_repetition_lock_before_withdrawal() +RETURNS TRIGGER AS $$ +BEGIN + -- Check if repetition is locked when trying to withdraw a submitted draft + IF NEW.status = 'withdrawn' AND OLD.status = 'submitted' THEN + IF EXISTS ( + SELECT 1 FROM public.experiment_repetitions + WHERE id = NEW.repetition_id AND is_locked = true + ) THEN + RAISE EXCEPTION 'Cannot withdraw submitted draft: repetition is locked by admin'; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER check_lock_before_withdrawal + BEFORE UPDATE ON public.experiment_phase_drafts + FOR EACH ROW + EXECUTE FUNCTION public.check_repetition_lock_before_withdrawal(); diff --git a/supabase/migrations/20250725000003_fix_draft_constraints.sql b/supabase/migrations/20250725000003_fix_draft_constraints.sql new file mode 100644 index 0000000..cc0542e --- /dev/null +++ b/supabase/migrations/20250725000003_fix_draft_constraints.sql @@ -0,0 +1,17 @@ +-- Fix Draft Constraints Migration +-- Allows multiple drafts per phase while preventing multiple submitted drafts + +-- Drop the overly restrictive constraint +ALTER TABLE public.experiment_phase_drafts +DROP CONSTRAINT IF EXISTS unique_user_phase_repetition_draft; + +-- Add a proper constraint that only prevents multiple submitted drafts +-- Users can have multiple drafts in 'draft' or 'withdrawn' status, but only one 'submitted' per phase +ALTER TABLE public.experiment_phase_drafts +ADD CONSTRAINT unique_submitted_draft_per_user_phase +EXCLUDE (user_id WITH =, repetition_id WITH =, phase_name WITH =) +WHERE (status = 'submitted'); + +-- Add comment explaining the constraint +COMMENT ON CONSTRAINT unique_submitted_draft_per_user_phase ON public.experiment_phase_drafts +IS 'Ensures only one submitted draft per user per phase per repetition, but allows multiple draft/withdrawn entries'; diff --git a/supabase/seed.sql b/supabase/seed.sql index 80a4faa..7448705 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -1,6 +1,9 @@ --- Seed data for testing experiment scheduling functionality +-- Seed data for testing experiment repetitions functionality --- Insert some sample experiments for testing +-- Insert experiments from phase_2_experimental_run_sheet.csv +-- These are experiment blueprints/templates with their parameters +-- Using run_number from CSV as experiment_number in database +-- Note: Some run_numbers are duplicated in the CSV, so we'll only insert unique ones INSERT INTO public.experiments ( experiment_number, reps_required, @@ -10,51 +13,118 @@ INSERT INTO public.experiments ( throughput_rate_pecans_sec, crush_amount_in, entry_exit_height_diff_in, - schedule_status, results_status, created_by -) VALUES -( - 1001, - 5, - 2.5, - 30, - 50.0, - 2.5, - 0.005, - 1.2, - 'pending schedule', - 'valid', - (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com') -), -( - 1002, - 3, - 1.0, - 15, - 45.0, - 3.0, - 0.003, - 0.8, - 'pending schedule', - 'valid', - (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com') -), -( - 1003, - 4, - 3.0, - 45, - 55.0, - 2.0, - 0.007, - 1.5, - 'scheduled', - 'valid', - (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com') -); +) VALUES +-- Unique experiments based on run_number from CSV +(0, 3, 34, 19, 53, 28, 0.05, -0.09, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +(1, 3, 24, 27, 34, 29, 0.03, 0.01, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +(2, 3, 38, 10, 60, 28, 0.06, -0.1, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +(3, 3, 11, 36, 42, 13, 0.07, -0.07, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +(4, 3, 13, 41, 41, 38, 0.05, 0.03, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +(5, 3, 30, 33, 30, 36, 0.05, -0.04, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +(6, 3, 10, 22, 37, 30, 0.06, 0.02, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +(7, 3, 15, 30, 35, 32, 0.05, -0.07, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +(8, 3, 27, 12, 55, 24, 0.04, 0.04, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +(9, 3, 32, 26, 47, 26, 0.07, 0.03, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +(10, 3, 26, 60, 44, 12, 0.08, -0.1, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +(11, 3, 24, 59, 42, 25, 0.07, -0.05, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +(12, 3, 28, 59, 37, 23, 0.06, -0.08, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +(13, 3, 21, 59, 41, 21, 0.06, -0.09, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +(14, 3, 22, 59, 45, 17, 0.07, -0.08, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +(15, 3, 16, 60, 30, 24, 0.07, 0.02, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +(16, 3, 20, 59, 41, 14, 0.07, 0.04, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +(17, 3, 34, 60, 34, 29, 0.07, -0.09, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +(18, 3, 18, 49, 38, 35, 0.07, -0.08, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +(19, 3, 11, 25, 56, 34, 0.06, -0.09, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')); --- Update one experiment to have a scheduled date for testing -UPDATE public.experiments -SET scheduled_date = NOW() + INTERVAL '2 days' -WHERE experiment_number = 1003; +-- Create repetitions for all experiments based on CSV data +-- Each experiment has 3 repetitions as specified in the CSV +INSERT INTO public.experiment_repetitions ( + experiment_id, + repetition_number, + schedule_status, + scheduled_date, + completion_status, + created_by +) VALUES +-- Experiment 0 repetitions +((SELECT id FROM public.experiments WHERE experiment_number = 0), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 0), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 0), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +-- Experiment 1 repetitions +((SELECT id FROM public.experiments WHERE experiment_number = 1), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 1), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 1), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +-- Experiment 2 repetitions +((SELECT id FROM public.experiments WHERE experiment_number = 2), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 2), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 2), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +-- Experiment 3 repetitions +((SELECT id FROM public.experiments WHERE experiment_number = 3), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 3), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 3), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +-- Experiment 4 repetitions +((SELECT id FROM public.experiments WHERE experiment_number = 4), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 4), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 4), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +-- Experiment 5 repetitions +((SELECT id FROM public.experiments WHERE experiment_number = 5), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 5), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 5), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +-- Experiment 6 repetitions +((SELECT id FROM public.experiments WHERE experiment_number = 6), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 6), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 6), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +-- Experiment 7 repetitions +((SELECT id FROM public.experiments WHERE experiment_number = 7), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 7), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 7), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +-- Experiment 8 repetitions +((SELECT id FROM public.experiments WHERE experiment_number = 8), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 8), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 8), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +-- Experiment 9 repetitions +((SELECT id FROM public.experiments WHERE experiment_number = 9), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 9), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 9), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +-- Experiment 10 repetitions +((SELECT id FROM public.experiments WHERE experiment_number = 10), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 10), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 10), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +-- Experiment 11 repetitions +((SELECT id FROM public.experiments WHERE experiment_number = 11), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 11), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 11), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +-- Experiment 12 repetitions +((SELECT id FROM public.experiments WHERE experiment_number = 12), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 12), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 12), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +-- Experiment 13 repetitions +((SELECT id FROM public.experiments WHERE experiment_number = 13), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 13), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 13), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +-- Experiment 14 repetitions +((SELECT id FROM public.experiments WHERE experiment_number = 14), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 14), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 14), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +-- Experiment 15 repetitions +((SELECT id FROM public.experiments WHERE experiment_number = 15), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 15), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 15), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +-- Experiment 16 repetitions +((SELECT id FROM public.experiments WHERE experiment_number = 16), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 16), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 16), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +-- Experiment 17 repetitions +((SELECT id FROM public.experiments WHERE experiment_number = 17), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 17), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 17), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +-- Experiment 18 repetitions +((SELECT id FROM public.experiments WHERE experiment_number = 18), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 18), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 18), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +-- Experiment 19 repetitions +((SELECT id FROM public.experiments WHERE experiment_number = 19), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 19), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')), +((SELECT id FROM public.experiments WHERE experiment_number = 19), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')); \ No newline at end of file