17 Commits

Author SHA1 Message Date
f8e6fccae3 Update robot-plugins submodule 2026-03-21 18:28:07 -04:00
3f87588fea fix: Update ROS topics and robot configuration
ROS Topic Fixes:
- wizard-ros-service.ts: Use correct ROS topics (/cmd_vel, /joint_angles, /speech)
- ros-bridge.ts: Update subscriptions to match naoqi_driver topics
- Fixes action execution (movement, speech, head control)

Robot Configuration:
- robots.ts: Use NAO_IP/NAO_ROBOT_IP env vars instead of hardcoded 'nao.local'
- robots.ts: Use NAO_PASSWORD env var for SSH authentication
- Improves Docker integration with NAO6

Wizard Interface:
- useWizardRos.ts: Enhanced wizard interface for robot control
- WizardInterface.tsx: Updated wizard controls
- Add comprehensive event listeners for robot actions
2026-03-21 17:58:29 -04:00
18e5aab4a5 feat: Convert robot-plugins to proper git submodule
- Removes nested .git directory from robot-plugins
- Adds robot-plugins as a proper submodule of hristudio
- Points to main branch of soconnor0919/robot-plugins repository
- This enables proper version tracking and updates of robot plugins
2026-03-21 17:57:54 -04:00
c16d0d2565 feat: Add uuid package and its types to dependencies. 2026-03-19 17:40:39 -04:00
c37acad3d2 feat: Enforce study membership access for file uploads and integrate live system statistics. 2026-03-06 00:22:22 -05:00
0051946bde feat: Implement digital signatures for participant consent and introduce study forms management. 2026-03-02 10:51:20 -05:00
61af467cc8 feat: enhance experiment designer action definitions, refactor trial analysis UI, and update video playback controls 2026-03-01 19:00:23 -05:00
60d4fae72c feat: Enhance trial event display with improved formatting and icons, refine trial wizard panels, and update dashboard page layouts. 2026-02-20 00:37:33 -05:00
72971a4b49 feat(analytics): refine timeline visualization and add print support 2026-02-17 21:17:11 -05:00
568d408587 feat: Add guided tour functionality for analytics and wizard components, including new tour steps and triggers. 2026-02-12 00:53:28 -05:00
93de577939 feat: Add a new onboarding tour for participant creation and refactor consent form uploads to use sonner for toasts and XMLHttpRequest for progress tracking. 2026-02-11 23:49:51 -05:00
85b951f742 refactor: restructure study and participant forms into logical sections with separators and enhance EntityForm's layout flexibility for sidebar presence. 2026-02-10 16:31:43 -05:00
a8c868ad3f feat: Implement trial event logging, archiving, experiment soft deletion, and new analytics/event data tables. 2026-02-10 16:14:31 -05:00
0f535f6887 feat: introduce conditional steps and branching logic to the experiment wizard and designer, along with new core and WoZ plugins. 2026-02-10 10:24:09 -05:00
388897c70e feat: Implement collapsible left and right panels with dynamic column spanning, updated styling, and integrated a bottom status bar in the DesignerRoot. 2026-02-03 13:58:47 -05:00
0ec63b3c97 feat: Redesign the designer layout using a grid system, adding explicit left, center, and right panels with collapse functionality. 2026-02-02 15:48:17 -05:00
89c44efcf7 feat: Implement responsive design for the experiment designer and enhance general UI components with hover effects and shadows. 2026-02-02 12:51:53 -05:00
197 changed files with 19867 additions and 8606 deletions

View File

@@ -1,7 +1,7 @@
module.exports = { module.exports = {
"extends": [".eslintrc.cjs"], extends: [".eslintrc.cjs"],
"rules": { rules: {
// Only enable the rule we want to autofix // Only enable the rule we want to autofix
"@typescript-eslint/prefer-nullish-coalescing": "error" "@typescript-eslint/prefer-nullish-coalescing": "error",
} },
}; };

4
.gitmodules vendored Normal file
View File

@@ -0,0 +1,4 @@
[submodule "robot-plugins"]
path = robot-plugins
url = git@github.com:soconnor0919/robot-plugins.git
branch = main

View File

@@ -19,12 +19,13 @@ HRIStudio addresses critical challenges in HRI research by providing a comprehen
- **Hierarchical Structure**: Study → Experiment → Trial → Step → Action - **Hierarchical Structure**: Study → Experiment → Trial → Step → Action
- **Visual Experiment Designer**: Drag-and-drop protocol creation with 26+ core blocks - **Visual Experiment Designer**: Drag-and-drop protocol creation with 26+ core blocks
- **Plugin System**: Extensible robot platform integration (RESTful, ROS2, custom) - **Plugin System**: Extensible robot platform integration (RESTful, ROS2, custom)
- **Consolidated Wizard Interface**: 3-panel design with trial controls, horizontal timeline, and unified robot controls
- **Real-time Trial Execution**: Live wizard control with comprehensive data capture - **Real-time Trial Execution**: Live wizard control with comprehensive data capture
- **Role-Based Access**: Administrator, Researcher, Wizard, Observer (4 distinct roles) - **Role-Based Access**: Administrator, Researcher, Wizard, Observer (4 distinct roles)
- **Unified Form Experiences**: 73% code reduction through standardized patterns - **Unified Form Experiences**: 73% code reduction through standardized patterns
- **Enterprise DataTables**: Advanced filtering, pagination, export capabilities - **Enterprise DataTables**: Advanced filtering, pagination, export capabilities
- **Real-time Trial Execution**: Professional wizard interface with live monitoring
- **Mock Robot Integration**: Complete simulation system for development and testing - **Mock Robot Integration**: Complete simulation system for development and testing
- **Intelligent Control Flow**: Loops with implicit approval, branching logic, parallel execution
## Quick Start ## Quick Start
@@ -96,6 +97,9 @@ bun dev
- Plugin Store with trust levels (Official, Verified, Community) - Plugin Store with trust levels (Official, Verified, Community)
#### 3. Adaptive Wizard Interface #### 3. Adaptive Wizard Interface
- **3-Panel Design**: Trial controls (left), horizontal timeline (center), robot control & status (right)
- **Horizontal Step Progress**: Non-scrolling step navigation with visual progress indicators
- **Consolidated Robot Controls**: Single location for connection, autonomous life, actions, and monitoring
- Real-time experiment execution dashboard - Real-time experiment execution dashboard
- Step-by-step guidance for consistent execution - Step-by-step guidance for consistent execution
- Quick actions for unscripted interventions - Quick actions for unscripted interventions

1077
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,18 @@ services:
- minio_data:/data - minio_data:/data
command: server --console-address ":9001" /data command: server --console-address ":9001" /data
createbuckets:
image: minio/mc
depends_on:
- minio
entrypoint: >
/bin/sh -c "
/usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin;
/usr/bin/mc mb myminio/hristudio;
/usr/bin/mc anonymous set public myminio/hristudio;
exit 0;
"
volumes: volumes:
postgres_data: postgres_data:
minio_data: minio_data:

View File

@@ -227,6 +227,7 @@ bun dev
### **Development Experience** ### **Development Experience**
- **Unified Components**: Significant reduction in code duplication - **Unified Components**: Significant reduction in code duplication
- **Panel Architecture**: 90% code sharing between experiment designer and wizard interface - **Panel Architecture**: 90% code sharing between experiment designer and wizard interface
- **Consolidated Wizard**: 3-panel design with trial controls, horizontal timeline, and unified robot controls
- **Enterprise DataTables**: Advanced filtering, export, pagination - **Enterprise DataTables**: Advanced filtering, export, pagination
- **Comprehensive Testing**: Realistic seed data with complete scenarios - **Comprehensive Testing**: Realistic seed data with complete scenarios
- **Developer Friendly**: Clear patterns and extensive documentation - **Developer Friendly**: Clear patterns and extensive documentation
@@ -253,11 +254,12 @@ bun dev
-**Database Schema** - 31 tables with comprehensive relationships -**Database Schema** - 31 tables with comprehensive relationships
-**Authentication** - Role-based access control system -**Authentication** - Role-based access control system
-**Visual Designer** - Repository-based plugin architecture -**Visual Designer** - Repository-based plugin architecture
-**Panel-Based Wizard Interface** - Consistent with experiment designer architecture -**Consolidated Wizard Interface** - 3-panel design with horizontal timeline and unified robot controls
-**Core Blocks System** - 26 blocks across events, wizard, control, observation -**Core Blocks System** - 26 blocks across events, wizard, control, observation
-**Plugin Architecture** - Unified system for core blocks and robot actions -**Plugin Architecture** - Unified system for core blocks and robot actions
-**Development Environment** - Realistic test data and scenarios -**Development Environment** - Realistic test data and scenarios
-**NAO6 Robot Integration** - Full ROS2 integration with comprehensive control and monitoring -**NAO6 Robot Integration** - Full ROS2 integration with comprehensive control and monitoring
-**Intelligent Control Flow** - Loops with implicit approval, branching, parallel execution
--- ---

View File

@@ -37,6 +37,13 @@ HRIStudio is a web-based platform designed to standardize and improve the reprod
- Context-sensitive help and best practice guidance - Context-sensitive help and best practice guidance
- Automatic generation of robot-specific action components - Automatic generation of robot-specific action components
- Parameter configuration with validation - Parameter configuration with validation
- **System Plugins**:
- **Core (`hristudio-core`)**: Control flow (loops, branches) and observation blocks
- **Wizard (`hristudio-woz`)**: Wizard interactions (speech, text input)
- **External Robot Plugins**:
- Located in `robot-plugins/` repository (e.g., `nao6-ros2`)
- Loaded dynamically per study
- Map abstract actions (Say, Walk) to ROS2 topics
- **Core Block Categories**: - **Core Block Categories**:
- Events (4): Trial triggers, speech detection, timers, key presses - Events (4): Trial triggers, speech detection, timers, key presses
- Wizard Actions (6): Speech, gestures, object handling, rating, notes - Wizard Actions (6): Speech, gestures, object handling, rating, notes

View File

@@ -2,278 +2,366 @@
## Overview ## Overview
The Wizard Interface is a real-time control panel for conducting Human-Robot Interaction (HRI) trials. It provides wizards with comprehensive tools to execute experiment protocols, monitor participant interactions, and control robot behaviors in real-time. The HRIStudio wizard interface provides a comprehensive, real-time trial execution environment with a consolidated 3-panel design optimized for efficient experiment control and monitoring.
## Key Features ## Interface Layout
- **Real-time Trial Execution**: Live step-by-step protocol execution with WebSocket connectivity ```
- **Robot Status Monitoring**: Battery levels, connection status, sensor readings, and position tracking ┌─────────────────────────────────────────────────────────────────────────────┐
- **Participant Information**: Demographics, consent status, and session details │ Trial Execution Header │
- **Live Event Logging**: Real-time capture of all trial events and wizard interventions │ [Trial Name] - [Participant] - [Status] │
- **Action Controls**: Quick access to common wizard actions and robot commands └─────────────────────────────────────────────────────────────────────────────┘
┌──────────────┬──────────────────────────────────────┬──────────────────────┐
## WebSocket System │ │ │ │
│ Trial │ Execution Timeline │ Robot Control │
### Connection Setup │ Control │ │ & Status │
│ │ │ │
The wizard interface automatically connects to a WebSocket server for real-time communication: │ ┌──────────┐ │ ┌──┬──┬──┬──┬──┐ Step Progress │ 📷 Camera View │
│ │ Start │ │ │✓ │✓ │● │ │ │ │ │
```typescript │ │ Pause │ │ └──┴──┴──┴──┴──┘ │ Connection: ✓ │
// WebSocket URL format │ │ Next Step│ │ │ │
wss://your-domain.com/api/websocket?trialId={TRIAL_ID}&token={AUTH_TOKEN} │ │ Complete │ │ Current Step: "Greeting" │ Autonomous Life: ON │
│ │ Abort │ │ ┌────────────────────────────────┐ │ │
│ └──────────┘ │ │ Actions: │ │ Robot Actions: │
│ │ │ • Say "Hello" [Run] │ │ ┌──────────────────┐ │
│ Progress: │ │ • Wave Hand [Run] │ │ │ Quick Commands │ │
│ Step 3/5 │ │ • Wait 2s [Run] │ │ └──────────────────┘ │
│ │ └────────────────────────────────┘ │ │
│ │ │ Movement Controls │
│ │ │ Quick Actions │
│ │ │ Status Monitoring │
└──────────────┴──────────────────────────────────────┴──────────────────────┘
``` ```
### Message Types ## Panel Descriptions
#### Incoming Messages (from server): ### Left Panel: Trial Control
- `connection_established` - Connection acknowledgment
- `trial_status` - Current trial state and step information
- `trial_action_executed` - Confirmation of action execution
- `step_changed` - Step transition notifications
- `intervention_logged` - Wizard intervention confirmations
#### Outgoing Messages (to server): **Purpose**: Manage overall trial flow and progression
- `heartbeat` - Keep connection alive
- `trial_action` - Execute trial actions (start, complete, abort)
- `wizard_intervention` - Log wizard interventions
- `step_transition` - Advance to next step
### Example Usage **Features:**
- **Start Trial**: Begin experiment execution
- **Pause/Resume**: Temporarily halt trial without aborting
- **Next Step**: Manually advance to next step (when all actions complete)
- **Complete Trial**: Mark trial as successfully completed
- **Abort Trial**: Emergency stop with reason logging
```typescript **Progress Indicators:**
// Start a trial - Current step number (e.g., "Step 3 of 5")
webSocket.sendMessage({ - Overall trial status
type: "trial_action", - Time elapsed
data: {
actionType: "start_trial",
step_index: 0,
data: { notes: "Trial started by wizard" }
}
});
// Log wizard intervention **Best Practices:**
webSocket.sendMessage({ - Use Pause for participant breaks
type: "wizard_intervention", - Use Abort only for unrecoverable issues
data: { - Document abort reasons thoroughly
action_type: "manual_correction",
step_index: currentStepIndex,
action_data: { message: "Clarified instruction" }
}
});
```
## Trial Execution Workflow
### 1. Pre-Trial Setup
- Verify participant consent and demographics
- Check robot connection and status
- Review experiment protocol steps
- Confirm WebSocket connectivity
### 2. Starting a Trial
1. Click "Start Trial" button
2. System automatically:
- Updates trial status to "in_progress"
- Records start timestamp
- Loads first protocol step
- Broadcasts status to all connected clients
### 3. Step-by-Step Execution
- **Current Step Display**: Shows active step details and actions
- **Execute Step**: Trigger step-specific actions (robot commands, wizard prompts)
- **Next Step**: Advance to subsequent protocol step
- **Quick Actions**: Access common wizard interventions
### 4. Real-time Monitoring
- **Robot Status**: Live updates on battery, signal, position, sensors
- **Event Log**: Chronological list of all trial events
- **Progress Tracking**: Visual progress bar and step completion status
### 5. Trial Completion
- Click "Complete" for successful trials
- Click "Abort" for early termination
- System records end timestamp and final status
- Automatic redirect to analysis page
## Experiment Data Integration
### Loading Real Experiment Steps
The wizard interface automatically loads experiment steps from the database:
```typescript
// Steps are fetched from the experiments API
const { data: experimentSteps } = api.experiments.getSteps.useQuery({
experimentId: trial.experimentId
});
```
### Step Types and Actions
Supported step types from the experiment designer:
- **Wizard Steps**: Manual wizard actions and prompts
- **Robot Steps**: Automated robot behaviors and movements
- **Parallel Steps**: Concurrent actions executed simultaneously
- **Conditional Steps**: Branching logic based on participant responses
## Seed Data and Testing
### Available Test Data
The development database includes realistic test scenarios:
```bash
# Seed the database with test data
bun db:seed
# Default login credentials
Email: sean@soconnor.dev
Password: password123
```
### Test Experiments
1. **"Basic Interaction Protocol 1"** (Study: Real-time HRI Coordination)
- 3 steps: Introduction, Wait for Response, Robot Feedback
- Includes wizard actions and NAO robot integration
- Estimated duration: 25 minutes
2. **"Dialogue Timing Pilot"** (Study: Wizard-of-Oz Dialogue Study)
- Multi-step protocol with parallel and conditional actions
- Timer-based transitions and conditional follow-ups
- Estimated duration: 35 minutes
### Test Participants
Pre-loaded participants with complete demographics:
- Various age groups (18-65)
- Different educational backgrounds
- Robot experience levels
- Consent already verified
## Robot Integration
### Supported Robots
- **TurtleBot3 Burger**: Navigation and sensing capabilities
- **NAO Humanoid Robot**: Speech, gestures, and animations
- **Plugin System**: Extensible support for additional platforms
### Robot Actions
Common robot actions available during trials:
- **Speech**: Text-to-speech with configurable speed/volume
- **Movement**: Navigation commands and position control
- **Gestures**: Pre-defined animation sequences
- **LED Control**: Visual feedback through color changes
- **Sensor Readings**: Real-time environmental data
## Error Handling and Troubleshooting
### WebSocket Connection Issues
- **Connection Failed**: Check network connectivity and server status
- **Frequent Disconnections**: Verify firewall settings and WebSocket support
- **Authentication Errors**: Ensure valid session and proper token generation
### Trial Execution Problems
- **Steps Not Loading**: Verify experiment has published steps in database
- **Robot Commands Failing**: Check robot connection and plugin configuration
- **Progress Not Updating**: Confirm WebSocket messages are being sent/received
### Recovery Procedures
1. **Connection Loss**: Interface automatically attempts reconnection with exponential backoff
2. **Trial State Mismatch**: Use "Refresh" button to sync with server state
3. **Robot Disconnect**: Monitor robot status panel for connection recovery
## Best Practices
### Wizard Guidelines
1. **Pre-Trial Preparation**
- Review complete experiment protocol
- Test robot functionality before participant arrival
- Verify audio/video recording systems
2. **During Trial Execution**
- Follow protocol steps in sequence
- Use intervention logging for any deviations
- Monitor participant comfort and engagement
- Watch robot status for any issues
3. **Post-Trial Procedures**
- Complete trial properly (don't just abort)
- Add summary notes about participant behavior
- Review event log for any anomalies
### Technical Considerations
- **Browser Compatibility**: Use modern browsers with WebSocket support
- **Network Requirements**: Stable internet connection for real-time features
- **Performance**: Close unnecessary browser tabs during trials
- **Backup Plans**: Have manual procedures ready if technology fails
## Development and Customization
### Adding Custom Actions
```typescript
// Register new wizard action
const handleCustomAction = async (actionData: Record<string, unknown>) => {
await logEventMutation.mutateAsync({
trialId: trial.id,
type: "wizard_action",
data: {
action_type: "custom_intervention",
...actionData
}
});
};
```
### Extending Robot Support
1. Create new robot plugin following plugin system guidelines
2. Define action schemas in plugin configuration
3. Implement communication protocol (REST/ROS2/WebSocket)
4. Test integration with wizard interface
### Custom Step Types
To add new step types:
1. Update database schema (`stepTypeEnum`)
2. Add type mapping in `WizardInterface.tsx`
3. Create step-specific UI components
4. Update execution engine logic
## Security Considerations
- **Authentication**: All WebSocket connections require valid session tokens
- **Authorization**: Role-based access control for trial operations
- **Data Protection**: All trial data encrypted in transit and at rest
- **Session Management**: Automatic cleanup of expired connections
## Performance Optimization
- **Connection Pooling**: Efficient WebSocket connection management
- **Event Batching**: Group related events to reduce message overhead
- **Selective Updates**: Only broadcast relevant changes to connected clients
- **Caching**: Local state management for responsive UI updates
--- ---
## Quick Start Checklist ### Center Panel: Execution Timeline
- [ ] Database seeded with test data (`bun db:seed`) **Purpose**: Visualize experiment flow and execute current step actions
- [ ] Development server running (`bun dev`)
- [ ] Logged in as administrator (sean@soconnor.dev)
- [ ] Navigate to Trials section
- [ ] Select a trial and click "Wizard Control"
- [ ] Verify WebSocket connection (green "Real-time" badge)
- [ ] Start trial and execute steps
- [ ] Monitor robot status and event log
- [ ] Complete trial and review analysis page
For additional support, refer to the complete HRIStudio documentation in the `docs/` folder. #### Horizontal Step Progress Bar
**Features:**
- **Visual Overview**: See all steps at a glance
- **Step States**:
-**Completed** (green checkmark, primary border)
-**Current** (highlighted, ring effect)
-**Upcoming** (muted appearance)
- **Click Navigation**: Jump to any step (unless read-only)
- **Horizontal Scroll**: For experiments with many steps
**Step Card Elements:**
- Step number or checkmark icon
- Truncated step name (hover for full name)
- Visual state indicators
#### Current Step View
**Features:**
- **Step Header**: Name and description
- **Action List**: Vertical timeline of actions
- **Action States**:
- Completed actions (checkmark)
- Active action (highlighted, pulsing)
- Pending actions (numbered)
- **Action Controls**: Run, Skip, Mark Complete buttons
- **Progress Tracking**: Auto-scrolls to active action
**Action Types:**
- **Wizard Actions**: Manual tasks for the wizard
- **Robot Actions**: Commands sent to the robot
- **Control Flow**: Loops, branches, parallel execution
- **Observations**: Data collection and recording
**Best Practices:**
- Review step description before starting
- Execute actions in order unless branching
- Use Skip sparingly and document reasons
- Verify robot action completion before proceeding
---
### Right Panel: Robot Control & Status
**Purpose**: Unified location for all robot-related controls and monitoring
#### Camera View
- Live video feed from robot or environment
- Multiple camera support (switchable)
- Full-screen mode available
#### Connection Status
- **ROS Bridge**: WebSocket connection state
- **Robot Status**: Online/offline indicator
- **Reconnect**: Manual reconnection button
- **Auto-reconnect**: Automatic retry on disconnect
#### Autonomous Life Toggle
- **Purpose**: Enable/disable robot's autonomous behaviors
- **States**:
- ON: Robot exhibits idle animations, breathing, awareness
- OFF: Robot remains still, fully manual control
- **Best Practice**: Turn OFF during precise interactions
#### Robot Actions Panel
- **Quick Commands**: Pre-configured robot actions
- **Parameter Controls**: Adjust action parameters
- **Execution Status**: Real-time feedback
- **Action History**: Recent commands log
#### Movement Controls
- **Directional Pad**: Manual robot navigation
- **Speed Control**: Adjust movement speed
- **Safety Limits**: Collision detection and boundaries
- **Emergency Stop**: Immediate halt
#### Quick Actions
- **Text-to-Speech**: Send custom speech commands
- **Preset Gestures**: Common robot gestures
- **LED Control**: Change robot LED colors
- **Posture Control**: Sit, stand, crouch commands
#### Status Monitoring
- **Battery Level**: Remaining charge percentage
- **Joint Status**: Motor temperatures and positions
- **Sensor Data**: Ultrasonic, tactile, IMU readings
- **Warnings**: Overheating, low battery, errors
**Best Practices:**
- Monitor battery level throughout trial
- Check connection status before robot actions
- Use Emergency Stop for safety concerns
- Document any robot malfunctions
---
## Workflow Guide
### Pre-Trial Setup
1. **Verify Robot Connection**
- Check ROS Bridge status (green indicator)
- Test robot responsiveness with quick action
- Confirm camera feed is visible
2. **Review Experiment Protocol**
- Scan horizontal step progress bar
- Review first step's actions
- Prepare any physical materials
3. **Configure Robot Settings** (Researchers/Admins only)
- Click Settings icon in robot panel
- Adjust speech, movement, connection parameters
- Save configuration for this study
### During Trial Execution
1. **Start Trial**
- Click "Start" in left panel
- First step becomes active
- First action highlights in timeline
2. **Execute Actions**
- Follow action sequence in center panel
- Use action controls (Run/Skip/Complete)
- Monitor robot status in right panel
- Document any deviations
3. **Navigate Steps**
- Wait for "Complete Step" button after all actions
- Click to advance to next step
- Or click step in progress bar to jump
4. **Handle Issues**
- **Participant Question**: Use Pause
- **Robot Malfunction**: Check status panel, use Emergency Stop if needed
- **Protocol Deviation**: Document in notes, continue or abort as appropriate
### Post-Trial Completion
1. **Complete Trial**
- Click "Complete Trial" after final step
- Confirm completion dialog
- Trial marked as completed
2. **Review Data**
- All actions logged with timestamps
- Robot commands recorded
- Sensor data captured
- Video recordings saved
---
## Control Flow Features
### Loops
**Behavior:**
- Loops execute their child actions repeatedly
- **Implicit Approval**: Wizard automatically approves each iteration
- **Manual Override**: Wizard can skip or abort loop
- **Progress Tracking**: Shows current iteration (e.g., "2 of 5")
**Best Practices:**
- Monitor participant engagement during loops
- Use abort if participant shows distress
- Document any skipped iterations
### Branches
**Behavior:**
- Conditional execution based on criteria
- Wizard selects branch path
- Only selected branch actions execute
- Other branches are skipped
**Best Practices:**
- Review branch conditions before choosing
- Document branch selection rationale
- Ensure participant meets branch criteria
### Parallel Execution
**Behavior:**
- Multiple actions execute simultaneously
- All must complete before proceeding
- Independent progress tracking
**Best Practices:**
- Monitor all parallel actions
- Be prepared for simultaneous robot and wizard tasks
- Coordinate timing carefully
---
## Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Space` | Start/Pause Trial |
| `→` | Next Step |
| `Esc` | Abort Trial (with confirmation) |
| `R` | Run Current Action |
| `S` | Skip Current Action |
| `C` | Complete Current Action |
| `E` | Emergency Stop Robot |
---
## Troubleshooting
### Robot Not Responding
1. Check ROS Bridge connection (right panel)
2. Click Reconnect button
3. Verify robot is powered on
4. Check network connectivity
5. Restart ROS Bridge if needed
### Camera Feed Not Showing
1. Verify camera is enabled in robot settings
2. Check camera topic in ROS
3. Refresh browser page
4. Check camera hardware connection
### Actions Not Progressing
1. Verify action has completed
2. Check for error messages
3. Manually mark complete if stuck
4. Document issue in trial notes
### Timeline Not Updating
1. Refresh browser page
2. Check WebSocket connection
3. Verify trial status is "in_progress"
4. Contact administrator if persists
---
## Role-Specific Features
### Wizards
- Full trial execution control
- Action execution and skipping
- Robot control (if permitted)
- Real-time decision making
### Researchers
- All wizard features
- Robot settings configuration
- Trial monitoring and oversight
- Protocol deviation approval
### Observers
- **Read-only access**
- View trial progress
- Monitor robot status
- Add annotations (no control)
### Administrators
- All features enabled
- System configuration
- Plugin management
- Emergency overrides
---
## Best Practices Summary
**Before Trial**
- Verify all connections
- Test robot responsiveness
- Review protocol thoroughly
**During Trial**
- Follow action sequence
- Monitor robot status continuously
- Document deviations immediately
- Use Pause for breaks, not Abort
**After Trial**
- Complete trial properly
- Review captured data
- Document any issues
- Debrief with participant
**Avoid**
- Skipping actions without documentation
- Ignoring robot warnings
- Aborting trials unnecessarily
- Deviating from protocol without approval
---
## Additional Resources
- **[Quick Reference](./quick-reference.md)** - Essential commands and shortcuts
- **[Implementation Details](./implementation-details.md)** - Technical architecture
- **[NAO6 Quick Reference](./nao6-quick-reference.md)** - Robot-specific commands
- **[Troubleshooting Guide](./nao6-integration-complete-guide.md)** - Detailed problem resolution

45
errors.txt Normal file
View File

@@ -0,0 +1,45 @@
scripts/seed-dev.ts(762,61): error TS2769: No overload matches this call.
Overload 1 of 2, '(value: { experimentId: string | SQL<unknown> | Placeholder<string, any>; duration?: number | SQL<unknown> | Placeholder<string, any> | null | undefined; id?: string | ... 2 more ... | undefined; ... 11 more ...; parameters?: unknown; }): PgInsertBase<...>', gave the following error.
Object literal may only specify known properties, and 'currentStepId' does not exist in type '{ experimentId: string | SQL<unknown> | Placeholder<string, any>; duration?: number | SQL<unknown> | Placeholder<string, any> | null | undefined; id?: string | SQL<...> | Placeholder<...> | undefined; ... 11 more ...; parameters?: unknown; }'.
Overload 2 of 2, '(values: { experimentId: string | SQL<unknown> | Placeholder<string, any>; duration?: number | SQL<unknown> | Placeholder<string, any> | null | undefined; id?: string | ... 2 more ... | undefined; ... 11 more ...; parameters?: unknown; }[]): PgInsertBase<...>', gave the following error.
Object literal may only specify known properties, and 'experimentId' does not exist in type '{ experimentId: string | SQL<unknown> | Placeholder<string, any>; duration?: number | SQL<unknown> | Placeholder<string, any> | null | undefined; id?: string | SQL<...> | Placeholder<...> | undefined; ... 11 more ...; parameters?: unknown; }[]'.
src/app/(dashboard)/studies/[id]/trials/[trialId]/analysis/page.tsx(99,13): error TS2322: Type '{ startedAt: Date | null; completedAt: Date | null; eventCount: any; mediaCount: any; media: { url: string; contentType: string; id: string; trialId: string; mediaType: "video" | "audio" | "image" | null; ... 8 more ...; createdAt: Date; }[]; ... 13 more ...; participant: { ...; }; }' is not assignable to type '{ id: string; status: string; startedAt: Date | null; completedAt: Date | null; duration: number | null; experiment: { name: string; studyId: string; }; participant: { participantCode: string; }; eventCount?: number | undefined; mediaCount?: number | undefined; media?: { ...; }[] | undefined; }'.
Types of property 'media' are incompatible.
Type '{ url: string; contentType: string; id: string; trialId: string; mediaType: "video" | "audio" | "image" | null; storagePath: string; fileSize: number | null; duration: number | null; format: string | null; ... 4 more ...; createdAt: Date; }[]' is not assignable to type '{ url: string; mediaType: string; format?: string | undefined; contentType?: string | undefined; }[]'.
Type '{ url: string; contentType: string; id: string; trialId: string; mediaType: "video" | "audio" | "image" | null; storagePath: string; fileSize: number | null; duration: number | null; format: string | null; ... 4 more ...; createdAt: Date; }' is not assignable to type '{ url: string; mediaType: string; format?: string | undefined; contentType?: string | undefined; }'.
Types of property 'mediaType' are incompatible.
Type 'string | null' is not assignable to type 'string'.
Type 'null' is not assignable to type 'string'.
src/lib/experiment-designer/__tests__/control-flow.test.ts(2,38): error TS2307: Cannot find module 'vitest' or its corresponding type declarations.
src/lib/experiment-designer/__tests__/control-flow.test.ts(64,16): error TS2532: Object is possibly 'undefined'.
src/lib/experiment-designer/__tests__/control-flow.test.ts(65,17): error TS2532: Object is possibly 'undefined'.
src/lib/experiment-designer/__tests__/control-flow.test.ts(70,16): error TS2532: Object is possibly 'undefined'.
src/lib/experiment-designer/__tests__/control-flow.test.ts(71,17): error TS2532: Object is possibly 'undefined'.
src/lib/experiment-designer/__tests__/control-flow.test.ts(72,17): error TS2532: Object is possibly 'undefined'.
src/lib/experiment-designer/__tests__/control-flow.test.ts(100,16): error TS2532: Object is possibly 'undefined'.
src/lib/experiment-designer/__tests__/control-flow.test.ts(101,17): error TS2532: Object is possibly 'undefined'.
src/lib/experiment-designer/__tests__/control-flow.test.ts(107,17): error TS2532: Object is possibly 'undefined'.
src/lib/experiment-designer/__tests__/control-flow.test.ts(108,17): error TS2532: Object is possibly 'undefined'.
src/lib/experiment-designer/__tests__/hashing.test.ts(2,38): error TS2307: Cannot find module 'vitest' or its corresponding type declarations.
src/lib/experiment-designer/__tests__/hashing.test.ts(65,19): error TS2741: Property 'category' is missing in type '{ id: string; type: string; name: string; parameters: { message: string; }; source: { kind: "core"; baseActionId: string; }; execution: { transport: "internal"; }; }' but required in type 'ExperimentAction'.
src/lib/experiment-designer/__tests__/hashing.test.ts(86,19): error TS2741: Property 'category' is missing in type '{ id: string; type: string; name: string; parameters: { message: string; }; source: { kind: "core"; baseActionId: string; }; execution: { transport: "internal"; }; }' but required in type 'ExperimentAction'.
src/lib/experiment-designer/__tests__/store.test.ts(2,50): error TS2307: Cannot find module 'vitest' or its corresponding type declarations.
src/lib/experiment-designer/__tests__/store.test.ts(39,16): error TS2532: Object is possibly 'undefined'.
src/lib/experiment-designer/__tests__/store.test.ts(58,16): error TS2532: Object is possibly 'undefined'.
src/lib/experiment-designer/__tests__/store.test.ts(103,16): error TS2532: Object is possibly 'undefined'.
src/lib/experiment-designer/__tests__/store.test.ts(104,16): error TS2532: Object is possibly 'undefined'.
src/lib/experiment-designer/__tests__/store.test.ts(107,16): error TS2532: Object is possibly 'undefined'.
src/lib/experiment-designer/__tests__/store.test.ts(108,16): error TS2532: Object is possibly 'undefined'.
src/lib/experiment-designer/__tests__/store.test.ts(123,15): error TS2741: Property 'category' is missing in type '{ id: string; type: string; name: string; parameters: {}; source: { kind: "core"; baseActionId: string; }; execution: { transport: "internal"; }; }' but required in type 'ExperimentAction'.
src/lib/experiment-designer/__tests__/store.test.ts(135,16): error TS18048: 'storedStep' is possibly 'undefined'.
src/lib/experiment-designer/__tests__/store.test.ts(136,16): error TS18048: 'storedStep' is possibly 'undefined'.
src/lib/experiment-designer/__tests__/store.test.ts(136,16): error TS2532: Object is possibly 'undefined'.
src/lib/experiment-designer/__tests__/validators.test.ts(2,38): error TS2307: Cannot find module 'vitest' or its corresponding type declarations.
src/lib/experiment-designer/__tests__/validators.test.ts(11,5): error TS2322: Type '"utility"' is not assignable to type 'ActionCategory'.
src/lib/experiment-designer/__tests__/validators.test.ts(14,91): error TS2353: Object literal may only specify known properties, and 'default' does not exist in type 'ActionParameter'.
src/lib/experiment-designer/__tests__/validators.test.ts(36,20): error TS2532: Object is possibly 'undefined'.
src/lib/experiment-designer/__tests__/validators.test.ts(58,17): error TS2353: Object literal may only specify known properties, and 'order' does not exist in type 'ExperimentAction'.
src/lib/experiment-designer/__tests__/validators.test.ts(78,17): error TS2353: Object literal may only specify known properties, and 'order' does not exist in type 'ExperimentAction'.
src/lib/experiment-designer/__tests__/validators.test.ts(107,17): error TS2353: Object literal may only specify known properties, and 'order' does not exist in type 'ExperimentAction'.
src/lib/experiment-designer/__tests__/validators.test.ts(119,20): error TS2532: Object is possibly 'undefined'.
src/server/services/__tests__/trial-execution.test.ts(2,56): error TS2307: Cannot find module 'bun:test' or its corresponding type declarations.

View File

@@ -23,87 +23,105 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@auth/drizzle-adapter": "^1.10.0", "@auth/drizzle-adapter": "^1.11.1",
"@aws-sdk/client-s3": "^3.859.0", "@aws-sdk/client-s3": "^3.989.0",
"@aws-sdk/s3-request-presigner": "^3.859.0", "@aws-sdk/s3-request-presigner": "^3.989.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.1.1", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8", "@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.5", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.8",
"@shadcn/ui": "^0.0.4", "@shadcn/ui": "^0.0.4",
"@t3-oss/env-nextjs": "^0.13.8", "@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-query": "^5.69.0", "@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@trpc/client": "^11.0.0", "@tiptap/extension-table": "^3.20.0",
"@trpc/react-query": "^11.0.0", "@tiptap/extension-table-cell": "^3.20.0",
"@trpc/server": "^11.0.0", "@tiptap/extension-table-header": "^3.20.0",
"@tiptap/extension-table-row": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"@tiptap/react": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"@trpc/client": "^11.10.0",
"@trpc/react-query": "^11.10.0",
"@trpc/server": "^11.10.0",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"driver.js": "^1.4.0", "driver.js": "^1.4.0",
"drizzle-orm": "^0.41.0", "drizzle-orm": "^0.41.0",
"html2pdf.js": "^0.14.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.536.0", "lucide-react": "^0.536.0",
"minio": "^8.0.6", "minio": "^8.0.6",
"next": "^16.1.6", "next": "^16.1.6",
"next-auth": "^5.0.0-beta.29", "next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"postgres": "^3.4.4", "postgres": "^3.4.8",
"react": "^19.0.0", "radix-ui": "^1.4.3",
"react-dom": "^19.0.0", "react": "^19.2.4",
"react-hook-form": "^7.60.0", "react-day-picker": "^9.13.2",
"react-resizable-panels": "^3.0.4", "react-dom": "^19.2.4",
"react-hook-form": "^7.71.1",
"react-resizable-panels": "^3.0.6",
"react-signature-canvas": "^1.1.0-alpha.2",
"react-webcam": "^7.2.0", "react-webcam": "^7.2.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"superjson": "^2.2.1", "superjson": "^2.2.6",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.4.0",
"ws": "^8.18.3", "tiptap-markdown": "^0.9.0",
"zod": "^4.0.5", "uuid": "^13.0.0",
"zustand": "^4.5.5" "ws": "^8.19.0",
"zod": "^4.3.6",
"zustand": "^4.5.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.3",
"@tailwindcss/postcss": "^4.0.15", "@tailwindcss/postcss": "^4.1.18",
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^3.0.0",
"@types/bun": "^1.3.9",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/node": "^20.14.10", "@types/node": "^20.19.33",
"@types/react": "^19.0.0", "@types/react": "^19.2.14",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.2.3",
"drizzle-kit": "^0.30.5", "@types/uuid": "^11.0.0",
"eslint": "^9.23.0", "drizzle-kit": "^0.30.6",
"eslint-config-next": "^15.2.3", "eslint": "^9.39.2",
"eslint-config-next": "^15.5.12",
"eslint-plugin-drizzle": "^0.2.3", "eslint-plugin-drizzle": "^0.2.3",
"postcss": "^8.5.3", "postcss": "^8.5.6",
"prettier": "^3.5.3", "prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.0.15", "tailwindcss": "^4.1.18",
"ts-unused-exports": "^11.0.1", "ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.3.5", "tw-animate-css": "^1.4.0",
"typescript": "^5.8.2", "typescript": "^5.9.3",
"typescript-eslint": "^8.27.0" "typescript-eslint": "^8.55.0",
"vitest": "^4.0.18"
}, },
"ct3aMetadata": { "ct3aMetadata": {
"initVersion": "7.39.3" "initVersion": "7.39.3"

View File

@@ -0,0 +1,46 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "../../src/server/db/schema";
import { eq } from "drizzle-orm";
const connectionString = process.env.DATABASE_URL!;
const connection = postgres(connectionString);
const db = drizzle(connection, { schema });
async function main() {
console.log("🔍 Checking seeded actions...");
const actions = await db.query.actions.findMany({
where: (actions, { or, eq, like }) =>
or(
eq(actions.type, "sequence"),
eq(actions.type, "parallel"),
eq(actions.type, "loop"),
eq(actions.type, "branch"),
like(actions.type, "hristudio-core%"),
),
limit: 10,
});
console.log(`Found ${actions.length} control actions.`);
for (const action of actions) {
console.log(`\nAction: ${action.name} (${action.type})`);
console.log(`ID: ${action.id}`);
// Explicitly log parameters to check structure
console.log("Parameters:", JSON.stringify(action.parameters, null, 2));
const params = action.parameters as any;
if (params.children) {
console.log(`✅ Has ${params.children.length} children in parameters.`);
} else if (params.trueBranch || params.falseBranch) {
console.log(`✅ Has branches in parameters.`);
} else {
console.log(`❌ No children/branches found in parameters.`);
}
}
await connection.end();
}
main();

View File

@@ -0,0 +1,66 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "../../src/server/db/schema";
import { eq } from "drizzle-orm";
const connectionString = process.env.DATABASE_URL!;
const connection = postgres(connectionString);
const db = drizzle(connection, { schema });
async function main() {
console.log("🔍 Checking Database State...");
// 1. Check Plugin
const plugins = await db.query.plugins.findMany();
console.log(`\nFound ${plugins.length} plugins.`);
const expectedKeys = new Set<string>();
for (const p of plugins) {
const meta = p.metadata as any;
const defs = p.actionDefinitions as any[];
console.log(`Plugin [${p.name}] (ID: ${p.id}):`);
console.log(` - Robot ID (Column): ${p.robotId}`);
console.log(` - Metadata.robotId: ${meta?.robotId}`);
console.log(` - Action Definitions: ${defs?.length ?? 0} found.`);
if (defs && meta?.robotId) {
defs.forEach((d) => {
const key = `${meta.robotId}.${d.id}`;
expectedKeys.add(key);
// console.log(` -> Registers: ${key}`);
});
}
}
// 2. Check Actions
const actions = await db.query.actions.findMany();
console.log(`\nFound ${actions.length} actions.`);
let errorCount = 0;
for (const a of actions) {
// Only check plugin actions
if (a.sourceKind === "plugin" || a.type.includes(".")) {
const isRegistered = expectedKeys.has(a.type);
const pluginIdMatch = a.pluginId === "nao6-ros2";
console.log(`Action [${a.name}] (Type: ${a.type}):`);
console.log(` - PluginId: ${a.pluginId} ${pluginIdMatch ? "✅" : "❌"}`);
console.log(` - In Registry: ${isRegistered ? "✅" : "❌"}`);
if (!isRegistered || !pluginIdMatch) errorCount++;
}
}
if (errorCount > 0) {
console.log(`\n❌ Found ${errorCount} actions with issues.`);
} else {
console.log(
"\n✅ All plugin actions validated successfully against registry definitions.",
);
}
await connection.end();
}
main().catch(console.error);

View File

@@ -0,0 +1,60 @@
import { db } from "~/server/db";
import { steps, experiments, actions } from "~/server/db/schema";
import { eq, asc } from "drizzle-orm";
async function debugExperimentStructure() {
console.log("Debugging Experiment Structure for Interactive Storyteller...");
// Find the experiment
const experiment = await db.query.experiments.findFirst({
where: eq(experiments.name, "The Interactive Storyteller"),
with: {
steps: {
orderBy: [asc(steps.orderIndex)],
with: {
actions: {
orderBy: [asc(actions.orderIndex)],
},
},
},
},
});
if (!experiment) {
console.error("Experiment not found!");
return;
}
console.log(`Experiment: ${experiment.name} (${experiment.id})`);
console.log(`Plugin Dependencies:`, experiment.pluginDependencies);
console.log("---------------------------------------------------");
experiment.steps.forEach((step, index) => {
console.log(`Step ${index + 1}: ${step.name}`);
console.log(` ID: ${step.id}`);
console.log(` Type: ${step.type}`);
console.log(` Order: ${step.orderIndex}`);
console.log(` Conditions:`, JSON.stringify(step.conditions, null, 2));
if (step.actions && step.actions.length > 0) {
console.log(` Actions (${step.actions.length}):`);
step.actions.forEach((action, actionIndex) => {
console.log(` ${actionIndex + 1}. [${action.type}] ${action.name}`);
if (action.type === "wizard_wait_for_response") {
console.log(
` Options:`,
JSON.stringify((action.parameters as any)?.options, null, 2),
);
}
});
}
console.log("---------------------------------------------------");
});
}
debugExperimentStructure()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,41 @@
import { db } from "../../src/server/db";
import { experiments, steps } from "../../src/server/db/schema";
import { eq } from "drizzle-orm";
async function inspectAllSteps() {
const result = await db.query.experiments.findMany({
with: {
steps: {
orderBy: (steps, { asc }) => [asc(steps.orderIndex)],
columns: {
id: true,
name: true,
type: true,
orderIndex: true,
conditions: true,
},
},
},
});
console.log(`Found ${result.length} experiments.`);
for (const exp of result) {
console.log(`Experiment: ${exp.name} (${exp.id})`);
for (const step of exp.steps) {
// Only print conditional steps or the first step
if (step.type === "conditional" || step.orderIndex === 0) {
console.log(` [${step.orderIndex}] ${step.name} (${step.type})`);
console.log(` Conditions: ${JSON.stringify(step.conditions)}`);
}
}
console.log("---");
}
}
inspectAllSteps()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,46 @@
import { db } from "~/server/db";
import { actions, steps } from "~/server/db/schema";
import { eq } from "drizzle-orm";
async function inspectAction() {
console.log("Inspecting Action 10851aef-e720-45fc-ba5e-05e1e3425dab...");
const actionId = "10851aef-e720-45fc-ba5e-05e1e3425dab";
const action = await db.query.actions.findFirst({
where: eq(actions.id, actionId),
with: {
step: {
columns: {
id: true,
name: true,
type: true,
conditions: true,
},
},
},
});
if (!action) {
console.error("Action not found!");
return;
}
console.log("Action Found:");
console.log(" Name:", action.name);
console.log(" Type:", action.type);
console.log(" Parameters:", JSON.stringify(action.parameters, null, 2));
console.log("Parent Step:");
console.log(" ID:", action.step.id);
console.log(" Name:", action.step.name);
console.log(" Type:", action.step.type);
console.log(" Conditions:", JSON.stringify(action.step.conditions, null, 2));
}
inspectAction()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,29 @@
import { db } from "~/server/db";
import { steps } from "~/server/db/schema";
import { eq, inArray } from "drizzle-orm";
async function inspectBranchSteps() {
console.log("Inspecting Steps 4 (Branch A) and 5 (Branch B)...");
const step4Id = "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5";
const step5Id = "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30";
const branchSteps = await db.query.steps.findMany({
where: inArray(steps.id, [step4Id, step5Id]),
});
branchSteps.forEach((step) => {
console.log(`Step: ${step.name} (${step.id})`);
console.log(` Type: ${step.type}`);
console.log(` Order: ${step.orderIndex}`);
console.log(` Conditions:`, JSON.stringify(step.conditions, null, 2));
console.log("---------------------------------------------------");
});
}
inspectBranchSteps()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,29 @@
import { db } from "../../src/server/db";
import { steps } from "../../src/server/db/schema";
import { eq, like } from "drizzle-orm";
async function checkSteps() {
const allSteps = await db
.select()
.from(steps)
.where(like(steps.name, "%Comprehension Check%"));
console.log("Found steps:", allSteps.length);
for (const step of allSteps) {
console.log("Step Name:", step.name);
console.log("Type:", step.type);
console.log("Conditions (typeof):", typeof step.conditions);
console.log(
"Conditions (value):",
JSON.stringify(step.conditions, null, 2),
);
}
}
checkSteps()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,62 @@
import { db } from "~/server/db";
import { steps, experiments } from "~/server/db/schema";
import { eq, asc } from "drizzle-orm";
async function inspectExperimentSteps() {
// Find experiment by ID
const experiment = await db.query.experiments.findFirst({
where: eq(experiments.id, "961d0cb1-256d-4951-8387-6d855a0ae603"),
});
if (!experiment) {
console.log("Experiment not found!");
return;
}
console.log(`Inspecting Experiment: ${experiment.name} (${experiment.id})`);
const experimentSteps = await db.query.steps.findMany({
where: eq(steps.experimentId, experiment.id),
orderBy: [asc(steps.orderIndex)],
with: {
actions: {
orderBy: (actions, { asc }) => [asc(actions.orderIndex)],
},
},
});
console.log(`Found ${experimentSteps.length} steps.`);
for (const step of experimentSteps) {
console.log("--------------------------------------------------");
console.log(`Step [${step.orderIndex}] ID: ${step.id}`);
console.log(`Name: ${step.name}`);
console.log(`Type: ${step.type}`);
if (step.type === "conditional") {
console.log("Conditions:", JSON.stringify(step.conditions, null, 2));
}
if (step.actions.length > 0) {
console.log("Actions:");
for (const action of step.actions) {
console.log(
` - [${action.orderIndex}] ${action.name} (${action.type})`,
);
if (action.type === "wizard_wait_for_response") {
console.log(
" Parameters:",
JSON.stringify(action.parameters, null, 2),
);
}
}
}
}
}
inspectExperimentSteps()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,32 @@
import { db } from "../../src/server/db";
import { experiments } from "../../src/server/db/schema";
import { eq } from "drizzle-orm";
async function inspectVisualDesign() {
const exps = await db.select().from(experiments);
for (const exp of exps) {
console.log(`Experiment: ${exp.name}`);
if (exp.visualDesign) {
const vd = exp.visualDesign as any;
console.log("Visual Design Steps:");
if (vd.steps && Array.isArray(vd.steps)) {
vd.steps.forEach((s: any, i: number) => {
console.log(` [${i}] ${s.name} (${s.type})`);
console.log(` Trigger: ${JSON.stringify(s.trigger)}`);
});
} else {
console.log(" No steps in visualDesign or invalid format.");
}
} else {
console.log(" No visualDesign blob.");
}
}
}
inspectVisualDesign()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,74 @@
import { db } from "~/server/db";
import { actions, steps } from "~/server/db/schema";
import { eq, sql } from "drizzle-orm";
async function patchActionParams() {
console.log("Patching Action Parameters for Interactive Storyteller...");
// Target Step IDs
const step3CondId = "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85"; // Step 3: Comprehension Check
const actionId = "10851aef-e720-45fc-ba5e-05e1e3425dab"; // Action: Wait for Choice
// 1. Get the authoritative conditions from the Step
const step = await db.query.steps.findFirst({
where: eq(steps.id, step3CondId),
});
if (!step) {
console.error("Step 3 not found!");
return;
}
const conditions = step.conditions as any;
const richOptions = conditions?.options;
if (!richOptions || !Array.isArray(richOptions)) {
console.error("Step 3 conditions are missing valid options!");
return;
}
console.log(
"Found rich options in Step:",
JSON.stringify(richOptions, null, 2),
);
// 2. Get the Action
const action = await db.query.actions.findFirst({
where: eq(actions.id, actionId),
});
if (!action) {
console.error("Action not found!");
return;
}
console.log(
"Current Action Parameters:",
JSON.stringify(action.parameters, null, 2),
);
// 3. Patch the Action Parameters
// We replace the simple string options with the rich object options
const currentParams = action.parameters as any;
const newParams = {
...currentParams,
options: richOptions, // Overwrite with rich options from step
};
console.log("New Action Parameters:", JSON.stringify(newParams, null, 2));
await db.execute(sql`
UPDATE hs_action
SET parameters = ${JSON.stringify(newParams)}::jsonb
WHERE id = ${actionId}
`);
console.log("Action parameters successfully patched.");
}
patchActionParams()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,100 @@
import { db } from "~/server/db";
import { steps } from "~/server/db/schema";
import { eq, sql } from "drizzle-orm";
async function patchBranchSteps() {
console.log("Patching branch steps for Interactive Storyteller...");
// Target Step IDs (From debug output)
const step3CondId = "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85"; // Step 3: Comprehension Check
const stepBranchAId = "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5"; // Step 4: Branch A (Correct)
const stepBranchBId = "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30"; // Step 5: Branch B (Incorrect)
const stepConclusionId = "cc3fbc7f-29e5-45e0-8d46-e80813c54292"; // Step 6: Conclusion
// Update Step 3 (The Conditional Step)
console.log("Updating Step 3 (Conditional Step)...");
const step3Conditional = await db.query.steps.findFirst({
where: eq(steps.id, step3CondId),
});
if (step3Conditional) {
const currentConditions = (step3Conditional.conditions as any) || {};
const options = currentConditions.options || [];
// Patch options to point to real step IDs
const newOptions = options.map((opt: any) => {
if (opt.value === "Correct") return { ...opt, nextStepId: stepBranchAId };
if (opt.value === "Incorrect")
return { ...opt, nextStepId: stepBranchBId };
return opt;
});
const newConditions = { ...currentConditions, options: newOptions };
await db.execute(sql`
UPDATE hs_step
SET conditions = ${JSON.stringify(newConditions)}::jsonb
WHERE id = ${step3CondId}
`);
console.log("Step 3 (Conditional) updated links.");
} else {
console.log("Step 3 (Conditional) not found.");
}
// Update Step 4 (Branch A)
console.log("Updating Step 4 (Branch A)...");
/*
Note: We already patched Step 4 in previous run but under wrong assumption?
Let's re-patch to be safe.
Debug output showed ID: 3a2dc0b7-a43e-4236-9b9e-f957abafc1e5
It should jump to Conclusion (cc3fbc7f...)
*/
const stepBranchA = await db.query.steps.findFirst({
where: eq(steps.id, stepBranchAId),
});
if (stepBranchA) {
const currentConditions =
(stepBranchA.conditions as Record<string, unknown>) || {};
const newConditions = {
...currentConditions,
nextStepId: stepConclusionId,
};
await db.execute(sql`
UPDATE hs_step
SET conditions = ${JSON.stringify(newConditions)}::jsonb
WHERE id = ${stepBranchAId}
`);
console.log("Step 4 (Branch A) updated jump target.");
}
// Update Step 5 (Branch B)
console.log("Updating Step 5 (Branch B)...");
const stepBranchB = await db.query.steps.findFirst({
where: eq(steps.id, stepBranchBId),
});
if (stepBranchB) {
const currentConditions =
(stepBranchB.conditions as Record<string, unknown>) || {};
const newConditions = {
...currentConditions,
nextStepId: stepConclusionId,
};
await db.execute(sql`
UPDATE hs_step
SET conditions = ${JSON.stringify(newConditions)}::jsonb
WHERE id = ${stepBranchBId}
`);
console.log("Step 5 (Branch B) updated jump target.");
}
}
patchBranchSteps()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,87 @@
import { convertDatabaseToSteps } from "../../src/lib/experiment-designer/block-converter";
import { type ExperimentStep } from "../../src/lib/experiment-designer/types";
// Mock DB Steps (simulating what experimentsRouter returns before conversion)
const mockDbSteps = [
{
id: "step-1",
name: "Step 1",
type: "wizard",
orderIndex: 0,
actions: [
{
id: "seq-1",
name: "Test Sequence",
type: "sequence",
parameters: {
children: [
{
id: "child-1",
name: "Child 1",
type: "wait",
parameters: { duration: 1 },
},
{
id: "child-2",
name: "Child 2",
type: "wait",
parameters: { duration: 2 },
},
],
},
},
],
},
];
// Mock Store Logic (simulating store.ts)
function cloneActions(actions: any[]): any[] {
return actions.map((a) => ({
...a,
children: a.children ? cloneActions(a.children) : undefined,
}));
}
function cloneSteps(steps: any[]): any[] {
return steps.map((s) => ({
...s,
actions: cloneActions(s.actions),
}));
}
console.log("🔹 Testing Hydration & Cloning...");
// 1. Convert DB -> Runtime
const runtimeSteps = convertDatabaseToSteps(mockDbSteps);
const seq = runtimeSteps[0]?.actions[0];
if (!seq) {
console.error("❌ Conversion Failed: Sequence action not found.");
process.exit(1);
}
console.log(`Runtime Children Count: ${seq.children?.length ?? "undefined"}`);
if (!seq.children || seq.children.length === 0) {
console.error("❌ Conversion Failed: Children not hydrated from parameters.");
process.exit(1);
}
// 2. Store Cloning
const clonedSteps = cloneSteps(runtimeSteps);
const clonedSeq = clonedSteps[0]?.actions[0];
if (!clonedSeq) {
console.error("❌ Cloning Failed: Sequence action lost.");
process.exit(1);
}
console.log(
`Cloned Children Count: ${clonedSeq.children?.length ?? "undefined"}`,
);
if (clonedSeq.children?.length === 2) {
console.log("✅ SUCCESS: Data hydrated and cloned correctly.");
} else {
console.error("❌ CLONING FAILED: Children lost during clone.");
}

View File

@@ -0,0 +1,136 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "../../src/server/db/schema";
import { sql } from "drizzle-orm";
// Database connection
const connectionString = process.env.DATABASE_URL!;
const connection = postgres(connectionString);
const db = drizzle(connection, { schema });
async function main() {
console.log("🌱 Seeding 'Control Flow Demo' experiment...");
try {
// 1. Find Admin User & Study
const user = await db.query.users.findFirst({
where: (users, { eq }) => eq(users.email, "sean@soconnor.dev"),
});
if (!user)
throw new Error(
"Admin user 'sean@soconnor.dev' not found. Run seed-dev.ts first.",
);
const study = await db.query.studies.findFirst({
where: (studies, { eq }) => eq(studies.name, "Comparative WoZ Study"),
});
if (!study)
throw new Error(
"Study 'Comparative WoZ Study' not found. Run seed-dev.ts first.",
);
// Find Robot
const robot = await db.query.robots.findFirst({
where: (robots, { eq }) => eq(robots.name, "NAO6"),
});
if (!robot)
throw new Error("Robot 'NAO6' not found. Run seed-dev.ts first.");
// 2. Create Experiment
const [experiment] = await db
.insert(schema.experiments)
.values({
studyId: study.id,
name: "Control Flow Demo",
description:
"Demonstration of enhanced control flow actions: Sequence, Parallel, Wait, Loop, Branch.",
version: 1,
status: "draft",
robotId: robot.id,
createdBy: user.id,
})
.returning();
if (!experiment) throw new Error("Failed to create experiment");
console.log(`✅ Created Experiment: ${experiment.id}`);
// 3. Create Steps
// Step 1: Sequence & Parallel
const [step1] = await db
.insert(schema.steps)
.values({
experimentId: experiment.id,
name: "Complex Action Structures",
description: "Demonstrating Sequence and Parallel groups",
type: "robot",
orderIndex: 0,
required: true,
durationEstimate: 30,
})
.returning();
// Step 2: Loops & Waits
const [step2] = await db
.insert(schema.steps)
.values({
experimentId: experiment.id,
name: "Repetition & Delays",
description: "Demonstrating Loop and Wait actions",
type: "robot",
orderIndex: 1,
required: true,
durationEstimate: 45,
})
.returning();
// 4. Create Actions
// --- Step 1 Actions ---
// Top-level Sequence
const seqId = `seq-${Date.now()}`;
await db.insert(schema.actions).values({
stepId: step1!.id,
name: "Introduction Sequence",
type: "sequence", // New type
orderIndex: 0,
parameters: {},
pluginId: "hristudio-core",
category: "control",
// No explicit children column in schema?
// Wait, schema.actions has "children" as jsonb or it's a recursive relationship?
// Let's check schema/types.
// Looking at ActionChip, it expects `action.children`.
// In DB, it's likely stored in `children` jsonb column if it exists, OR we need to perform recursive inserts if schema supports parentId.
// Checking `types.ts` or schema...
// Assuming flat list references for now or JSONB.
// Wait, `ExperimentAction` in types has `children?: ExperimentAction[]`.
// If the DB schema `actions` table handles nesting via `parameters` or specific column, I need to know.
// Defaulting to "children" property in JSON parameter if DB doesn't have parentId.
// Checking `schema.ts`: "children" is likely NOT a column if I haven't seen it in seed-dev.
// However, `ActionChip` uses `action.children`. Steps map to `actions`.
// If `actions` table has `parentId` or `children` JSONB.
// I will assume `children` is part of the `parameters` or a simplified representation for now,
// BUT `FlowWorkspace` treats `action.children` as real actions.
// Let's check `schema.ts` quickly.
});
// I need to check schema.actions definition effectively.
// For this pass, I will insert them as flat actions since I can't confirm nesting storage without checking schema.
// But the user WANTS to see the nesting (Sequence, Parallel).
// The `SortableActionChip` renders `action.children`.
// The `TrialExecutionEngine` executes `action.children`.
// So the data MUST include children.
// Most likely `actions` table has a `children` JSONB column.
// I will insert a Parallel action with embedded children in the `children` column (if it exists) or `parameters`.
// Re-reading `scripts/seed-dev.ts`: It doesn't show any nested actions.
// I will read `src/server/db/schema.ts` to be sure.
} catch (err) {
console.error(err);
process.exit(1);
}
}
// I'll write the file AFTER checking schema to ensure I structure the nested actions correctly.

View File

@@ -0,0 +1,254 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "../../src/server/db/schema";
import { sql } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
// Database connection
const connectionString = process.env.DATABASE_URL!;
const connection = postgres(connectionString);
const db = drizzle(connection, { schema });
async function main() {
console.log("🌱 Seeding 'Control Flow Demo' experiment...");
try {
// 1. Find Admin User & Study
const user = await db.query.users.findFirst({
where: (users, { eq }) => eq(users.email, "sean@soconnor.dev"),
});
if (!user)
throw new Error(
"Admin user 'sean@soconnor.dev' not found. Run seed-dev.ts first.",
);
const study = await db.query.studies.findFirst({
where: (studies, { eq }) => eq(studies.name, "Comparative WoZ Study"),
});
if (!study)
throw new Error(
"Study 'Comparative WoZ Study' not found. Run seed-dev.ts first.",
);
// Find Robot
const robot = await db.query.robots.findFirst({
where: (robots, { eq }) => eq(robots.name, "NAO6"),
});
if (!robot)
throw new Error("Robot 'NAO6' not found. Run seed-dev.ts first.");
// 2. Create Experiment
const [experiment] = await db
.insert(schema.experiments)
.values({
studyId: study.id,
name: "Control Flow Demo",
description:
"Demonstration of enhanced control flow actions: Sequence, Parallel, Wait, Loop, Branch.",
version: 1,
status: "draft",
robotId: robot.id,
createdBy: user.id,
})
.returning();
if (!experiment) throw new Error("Failed to create experiment");
console.log(`✅ Created Experiment: ${experiment.id}`);
// 3. Create Steps
// Step 1: Sequence & Parallel
const [step1] = await db
.insert(schema.steps)
.values({
experimentId: experiment.id,
name: "Complex Action Structures",
description: "Demonstrating Sequence and Parallel groups",
type: "robot",
orderIndex: 0,
required: true,
durationEstimate: 30,
})
.returning();
if (!step1) throw new Error("Failed to create step1");
// Step 2: Loops & Waits
const [step2] = await db
.insert(schema.steps)
.values({
experimentId: experiment.id,
name: "Repetition & Delays",
description: "Demonstrating Loop and Wait actions",
type: "robot",
orderIndex: 1,
required: true,
durationEstimate: 45,
})
.returning();
if (!step2) throw new Error("Failed to create step2");
// 4. Create Actions
// --- Step 1 Actions ---
// Action 1: Sequence
// Note: Nested children are stored in 'children' property of the action object in frontend,
// but in DB 'parameters' is the JSONB field.
// However, looking at ActionChip, it expects `action.children`.
// The `ExperimentAction` type usually has `children` at top level.
// If the DB doesn't have it, the API must be hydrating it.
// BUT, for the purpose of this seed which writes to DB directly, I will put it in `parameters.children`
// and assume the frontend/API handles it or I'm missing a column.
// Actually, looking at schema again, `actions` table DOES NOT have children.
// So it MUST be in `parameters` or it's not persisted in this table structure yet (which would be a bug, but I'm seeding what exists).
// Wait, if I put it in parameters, does the UI read it?
// `ActionChip` reads `action.children`.
// I will try to put it in `parameters` and distinct `children` property in the JSON passed to `parameters`?
// No, `parameters` is jsonb.
// I will assume for now that the system expects it in parameters if it's not a column, OR it's not fully supported in DB yet.
// I will stick to what the UI likely consumes. `parameters: { children: [...] }`
// Sequence
await db.insert(schema.actions).values({
stepId: step1.id,
name: "Introduction Sequence",
type: "sequence",
orderIndex: 0,
// Embedding children here to demonstrate.
// Real implementation might vary if keys are strictly checked.
parameters: {
children: [
{
id: uuidv4(),
name: "Say Hello",
type: "nao6-ros2.say_text",
parameters: { text: "Hello there!" },
category: "interaction",
},
{
id: uuidv4(),
name: "Wave Hand",
type: "nao6-ros2.move_arm",
parameters: { arm: "right", action: "wave" },
category: "movement",
},
],
},
pluginId: "hristudio-core",
category: "control",
sourceKind: "core",
});
// Parallel
await db.insert(schema.actions).values({
stepId: step1.id,
name: "Parallel Actions",
type: "parallel",
orderIndex: 1,
parameters: {
children: [
{
id: uuidv4(),
name: "Say 'Moving'",
type: "nao6-ros2.say_text",
parameters: { text: "I am moving and talking." },
category: "interaction",
},
{
id: uuidv4(),
name: "Walk Forward",
type: "nao6-ros2.move_to",
parameters: { x: 0.5, y: 0 },
category: "movement",
},
],
},
pluginId: "hristudio-core",
category: "control",
sourceKind: "core",
});
// --- Step 2 Actions ---
// Loop
await db.insert(schema.actions).values({
stepId: step2.id,
name: "Repeat Message",
type: "loop",
orderIndex: 0,
parameters: {
iterations: 3,
children: [
{
id: uuidv4(),
name: "Say 'Echo'",
type: "nao6-ros2.say_text",
parameters: { text: "Echo" },
category: "interaction",
},
],
},
pluginId: "hristudio-core",
category: "control",
sourceKind: "core",
});
// Wait
await db.insert(schema.actions).values({
stepId: step2.id,
name: "Wait 5 Seconds",
type: "wait",
orderIndex: 1,
parameters: { duration: 5 },
pluginId: "hristudio-core",
category: "control",
sourceKind: "core",
});
// Branch (Controls step routing, not nested actions)
// Note: Branch configuration is stored in step.trigger.conditions, not action.parameters
// The branch action itself is just a marker that this step has conditional routing
await db.insert(schema.actions).values({
stepId: step2.id,
name: "Conditional Routing",
type: "branch",
orderIndex: 2,
parameters: {
// Branch actions don't have nested children
// Routing is configured at the step level via trigger.conditions
},
pluginId: "hristudio-core",
category: "control",
sourceKind: "core",
});
// Update step2 to have conditional routing
await db
.update(schema.steps)
.set({
type: "conditional",
conditions: {
options: [
{
label: "High Score Path",
nextStepIndex: 2, // Would go to a hypothetical step 3
variant: "default",
},
{
label: "Low Score Path",
nextStepIndex: 0, // Loop back to step 1
variant: "outline",
},
],
},
})
.where(sql`id = ${step2.id}`);
} catch (err) {
console.error(err);
process.exit(1);
} finally {
await connection.end();
}
}
main();

View File

@@ -0,0 +1,92 @@
// Mock of the logic in WizardInterface.tsx handleNextStep
const steps = [
{
id: "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85",
name: "Step 3 (Conditional)",
order: 2,
},
{
id: "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5",
name: "Step 4 (Branch A)",
order: 3,
conditions: {
nextStepId: "cc3fbc7f-29e5-45e0-8d46-e80813c54292",
},
},
{
id: "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30",
name: "Step 5 (Branch B)",
order: 4,
conditions: {
nextStepId: "cc3fbc7f-29e5-45e0-8d46-e80813c54292",
},
},
{
id: "cc3fbc7f-29e5-45e0-8d46-e80813c54292",
name: "Step 6 (Conclusion)",
order: 5,
},
];
function simulateNextStep(currentStepIndex: number) {
const currentStep = steps[currentStepIndex];
if (!currentStep) {
console.log("No step found at index:", currentStepIndex);
return;
}
console.log(`\n--- Simulating Next Step from: ${currentStep.name} ---`);
console.log("Current Step Data:", JSON.stringify(currentStep, null, 2));
// Logic from WizardInterface.tsx
console.log(
"[WizardInterface] Checking for nextStepId condition:",
currentStep?.conditions,
);
if (currentStep?.conditions?.nextStepId) {
const nextId = String(currentStep.conditions.nextStepId);
const targetIndex = steps.findIndex((s) => s.id === nextId);
console.log(`Target ID: ${nextId}`);
console.log(`Target Index Found: ${targetIndex}`);
if (targetIndex !== -1) {
console.log(
`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`,
);
return targetIndex;
} else {
console.warn(
`[WizardInterface] Targeted nextStepId ${nextId} not found in steps list.`,
);
}
} else {
console.log(
"[WizardInterface] No nextStepId found in conditions, proceeding linearly.",
);
}
// Default: Linear progression
const nextIndex = currentStepIndex + 1;
console.log(`Proceeding linearly to index ${nextIndex}`);
return nextIndex;
}
// Simulate Branch A (Index 1 in this array, but 3 in real experiment?)
// In real exp, Step 3 is index 2. Step 4 (Branch A) is index 3.
console.log("Real experiment indices:");
// 0: Hook, 1: Narrative, 2: Conditional, 3: Branch A, 4: Branch B, 5: Conclusion
const indexStep4 = 1; // logical index in my mock array
const indexStep5 = 2; // logical index
console.log("Testing Branch A Logic:");
const resultA = simulateNextStep(indexStep4);
if (resultA === 3) console.log("SUCCESS: Branch A jumped to Conclusion");
else console.log("FAILURE: Branch A fell through");
console.log("\nTesting Branch B Logic:");
const resultB = simulateNextStep(indexStep5);
if (resultB === 3) console.log("SUCCESS: Branch B jumped to Conclusion");
else console.log("FAILURE: Branch B fell through");

View File

@@ -0,0 +1,59 @@
import { convertDatabaseToAction } from "../../src/lib/experiment-designer/block-converter";
const mockDbAction = {
id: "eaf8f85b-75cf-4973-b436-092516b4e0e4",
name: "Introduction Sequence",
description: null,
type: "sequence",
orderIndex: 0,
parameters: {
children: [
{
id: "75018b01-a964-41fb-8612-940a29020d4a",
name: "Say Hello",
type: "nao6-ros2.say_text",
category: "interaction",
parameters: {
text: "Hello there!",
},
},
{
id: "d7020530-6477-41f3-84a4-5141778c93da",
name: "Wave Hand",
type: "nao6-ros2.move_arm",
category: "movement",
parameters: {
arm: "right",
action: "wave",
},
},
],
},
timeout: null,
retryCount: 0,
sourceKind: "core",
pluginId: "hristudio-core",
pluginVersion: null,
robotId: null,
baseActionId: null,
category: "control",
transport: null,
ros2: null,
rest: null,
retryable: null,
parameterSchemaRaw: null,
};
console.log("Testing convertDatabaseToAction...");
try {
const result = convertDatabaseToAction(mockDbAction);
console.log("Result:", JSON.stringify(result, null, 2));
if (result.children && result.children.length > 0) {
console.log("✅ Children hydrated successfully.");
} else {
console.error("❌ Children NOT hydrated.");
}
} catch (e) {
console.error("❌ Error during conversion:", e);
}

View File

@@ -0,0 +1,74 @@
import { appRouter } from "../../src/server/api/root";
import { createCallerFactory } from "../../src/server/api/trpc";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "../../src/server/db/schema";
import { eq } from "drizzle-orm";
// 1. Setup DB Context
const connectionString = process.env.DATABASE_URL!;
const connection = postgres(connectionString);
const db = drizzle(connection, { schema });
// 2. Mock Session
const mockSession = {
user: {
id: "0e830889-ab46-4b48-a8ba-1d4bd3e665ed", // Admin user ID from seed
name: "Sean O'Connor",
email: "sean@soconnor.dev",
},
expires: new Date().toISOString(),
};
// 3. Create Caller
const createCaller = createCallerFactory(appRouter);
const caller = createCaller({
db,
session: mockSession as any,
headers: new Headers(),
});
async function main() {
console.log("🔍 Fetching experiment via TRPC caller...");
// Get ID first
const exp = await db.query.experiments.findFirst({
where: eq(schema.experiments.name, "Control Flow Demo"),
columns: { id: true },
});
if (!exp) {
console.error("❌ Experiment not found");
return;
}
const result = await caller.experiments.get({ id: exp.id });
console.log(`✅ Fetched experiment: ${result.name} (${result.id})`);
if (result.steps && result.steps.length > 0) {
console.log(`Checking ${result.steps.length} steps...`);
const actions = result.steps[0]!.actions; // Step 1 actions
console.log(`Step 1 has ${actions.length} actions.`);
actions.forEach((a) => {
if (["sequence", "parallel", "loop", "branch"].includes(a.type)) {
console.log(`\nAction: ${a.name} (${a.type})`);
console.log(
`Children Count: ${a.children ? a.children.length : "UNDEFINED"}`,
);
if (a.children && a.children.length > 0) {
console.log(
`First Child: ${a.children[0]!.name} (${a.children[0]!.type})`,
);
}
}
});
} else {
console.error("❌ No steps found in result.");
}
await connection.end();
}
main();

View File

@@ -0,0 +1,46 @@
import { db } from "../../src/server/db";
import { experiments } from "../../src/server/db/schema";
import { eq, asc } from "drizzle-orm";
import { convertDatabaseToSteps } from "../../src/lib/experiment-designer/block-converter";
async function verifyConversion() {
const experiment = await db.query.experiments.findFirst({
with: {
steps: {
orderBy: (steps, { asc }) => [asc(steps.orderIndex)],
with: {
actions: {
orderBy: (actions, { asc }) => [asc(actions.orderIndex)],
},
},
},
},
});
if (!experiment) {
console.log("No experiment found");
return;
}
console.log("Raw DB Steps Count:", experiment.steps.length);
const converted = convertDatabaseToSteps(experiment.steps);
console.log("Converted Steps:");
converted.forEach((s, idx) => {
console.log(`[${idx}] ${s.name} (${s.type})`);
console.log(` Trigger:`, JSON.stringify(s.trigger));
if (s.type === "conditional") {
console.log(
` Conditions populated?`,
Object.keys(s.trigger.conditions).length > 0,
);
}
});
}
verifyConversion()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,107 @@
import { drizzle } from "drizzle-orm/postgres-js";
import { eq, sql } from "drizzle-orm";
import postgres from "postgres";
import * as schema from "../../src/server/db/schema";
const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString);
const db = drizzle(client, { schema });
async function verify() {
console.log("🔍 Verifying Study Readiness...");
// 1. Check Study
const study = await db.query.studies.findFirst({
where: eq(schema.studies.name, "Comparative WoZ Study"),
});
if (!study) {
console.error("❌ Study 'Comparative WoZ Study' not found.");
process.exit(1);
}
console.log("✅ Study found:", study.name);
// 2. Check Experiment
const experiment = await db.query.experiments.findFirst({
where: eq(schema.experiments.name, "The Interactive Storyteller"),
});
if (!experiment) {
console.error("❌ Experiment 'The Interactive Storyteller' not found.");
process.exit(1);
}
console.log("✅ Experiment found:", experiment.name);
// 3. Check Steps
const steps = await db.query.steps.findMany({
where: eq(schema.steps.experimentId, experiment.id),
orderBy: schema.steps.orderIndex,
});
console.log(` Found ${steps.length} steps.`);
if (steps.length < 5) {
console.error("❌ Expected at least 5 steps, found " + steps.length);
process.exit(1);
}
// Verify Step Names
const expectedSteps = [
"The Hook",
"The Narrative - Part 1",
"Comprehension Check",
"Positive Feedback",
"Conclusion",
];
for (let i = 0; i < expectedSteps.length; i++) {
const step = steps[i];
if (!step) continue;
if (step.name !== expectedSteps[i]) {
console.error(
`❌ Step mismatch at index ${i}. Expected '${expectedSteps[i]}', got '${step.name}'`,
);
} else {
console.log(`✅ Step ${i + 1}: ${step.name}`);
}
}
// 4. Check Plugin Actions
// Find the NAO6 plugin
const plugin = await db.query.plugins.findFirst({
where: (plugins, { eq, and }) =>
and(
eq(plugins.name, "NAO6 Robot (Enhanced ROS2 Integration)"),
eq(plugins.status, "active"),
),
});
if (!plugin) {
console.error("❌ NAO6 Plugin not found.");
process.exit(1);
}
const actions = plugin.actionDefinitions as any[];
const requiredActions = [
"nao_nod",
"nao_shake_head",
"nao_bow",
"nao_open_hand",
];
for (const actionId of requiredActions) {
const found = actions.find((a) => a.id === actionId);
if (!found) {
console.error(`❌ Plugin missing action: ${actionId}`);
process.exit(1);
}
console.log(`✅ Plugin has action: ${actionId}`);
}
console.log("🎉 Verification Complete: Platform is ready for the study!");
process.exit(0);
}
verify().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,86 @@
import { db } from "~/server/db";
import { experiments, steps, actions } from "~/server/db/schema";
import { eq, asc, desc } from "drizzle-orm";
import { convertDatabaseToSteps } from "~/lib/experiment-designer/block-converter";
async function verifyTrpcLogic() {
console.log("Verifying TRPC Logic for Interactive Storyteller...");
// 1. Simulate the DB Query from experiments.ts
const experiment = await db.query.experiments.findFirst({
where: eq(experiments.name, "The Interactive Storyteller"),
with: {
study: {
columns: {
id: true,
name: true,
},
},
createdBy: {
columns: {
id: true,
name: true,
email: true,
},
},
robot: true,
steps: {
with: {
actions: {
orderBy: [asc(actions.orderIndex)],
},
},
orderBy: [asc(steps.orderIndex)],
},
},
});
if (!experiment) {
console.error("Experiment not found!");
return;
}
// 2. Simulate the Transformation
console.log("Transforming DB steps to Designer steps...");
const transformedSteps = convertDatabaseToSteps(experiment.steps);
// 3. Inspect Step 4 (Branch A)
// Step index 3 (0-based) is Branch A
const branchAStep = transformedSteps[3];
if (branchAStep) {
console.log("Step 4 (Branch A):", branchAStep.name);
console.log(" Type:", branchAStep.type);
console.log(" Trigger:", JSON.stringify(branchAStep.trigger, null, 2));
} else {
console.error("Step 4 (Branch A) not found in transformed steps!");
process.exit(1);
}
// Check conditions specifically
const conditions = branchAStep.trigger?.conditions as any;
if (conditions?.nextStepId) {
console.log(
"SUCCESS: nextStepId found in conditions:",
conditions.nextStepId,
);
} else {
console.error("FAILURE: nextStepId MISSING in conditions!");
}
// Inspect Step 5 (Branch B) for completeness
const branchBStep = transformedSteps[4];
if (branchBStep) {
console.log("Step 5 (Branch B):", branchBStep.name);
console.log(" Trigger:", JSON.stringify(branchBStep.trigger, null, 2));
} else {
console.warn("Step 5 (Branch B) not found in transformed steps.");
}
}
verifyTrpcLogic()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});

25
scripts/get-demo-id.ts Normal file
View File

@@ -0,0 +1,25 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "../src/server/db/schema";
import { eq } from "drizzle-orm";
const connectionString = process.env.DATABASE_URL!;
const connection = postgres(connectionString);
const db = drizzle(connection, { schema });
async function main() {
const exp = await db.query.experiments.findFirst({
where: eq(schema.experiments.name, "Control Flow Demo"),
columns: { id: true },
});
if (exp) {
console.log(`Experiment ID: ${exp.id}`);
} else {
console.error("Experiment not found");
}
await connection.end();
}
main();

25
scripts/get-user-id.ts Normal file
View File

@@ -0,0 +1,25 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "../src/server/db/schema";
import { eq } from "drizzle-orm";
const connectionString = process.env.DATABASE_URL!;
const connection = postgres(connectionString);
const db = drizzle(connection, { schema });
async function main() {
const user = await db.query.users.findFirst({
where: eq(schema.users.email, "sean@soconnor.dev"),
columns: { id: true },
});
if (user) {
console.log(`User ID: ${user.id}`);
} else {
console.error("User not found");
}
await connection.end();
}
main();

File diff suppressed because it is too large Load Diff

View File

@@ -1,90 +0,0 @@
import { drizzle } from "drizzle-orm/postgres-js";
import { eq, sql } from "drizzle-orm";
import postgres from "postgres";
import * as schema from "../src/server/db/schema";
const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString);
const db = drizzle(client, { schema });
async function verify() {
console.log("🔍 Verifying Study Readiness...");
// 1. Check Study
const study = await db.query.studies.findFirst({
where: eq(schema.studies.name, "Comparative WoZ Study")
});
if (!study) {
console.error("❌ Study 'Comparative WoZ Study' not found.");
process.exit(1);
}
console.log("✅ Study found:", study.name);
// 2. Check Experiment
const experiment = await db.query.experiments.findFirst({
where: eq(schema.experiments.name, "The Interactive Storyteller")
});
if (!experiment) {
console.error("❌ Experiment 'The Interactive Storyteller' not found.");
process.exit(1);
}
console.log("✅ Experiment found:", experiment.name);
// 3. Check Steps
const steps = await db.query.steps.findMany({
where: eq(schema.steps.experimentId, experiment.id),
orderBy: schema.steps.orderIndex
});
console.log(` Found ${steps.length} steps.`);
if (steps.length < 5) {
console.error("❌ Expected at least 5 steps, found " + steps.length);
process.exit(1);
}
// Verify Step Names
const expectedSteps = ["The Hook", "The Narrative - Part 1", "Comprehension Check", "Positive Feedback", "Conclusion"];
for (let i = 0; i < expectedSteps.length; i++) {
const step = steps[i];
if (!step) continue;
if (step.name !== expectedSteps[i]) {
console.error(`❌ Step mismatch at index ${i}. Expected '${expectedSteps[i]}', got '${step.name}'`);
} else {
console.log(`✅ Step ${i + 1}: ${step.name}`);
}
}
// 4. Check Plugin Actions
// Find the NAO6 plugin
const plugin = await db.query.plugins.findFirst({
where: (plugins, { eq, and }) => and(eq(plugins.name, "NAO6 Robot (Enhanced ROS2 Integration)"), eq(plugins.status, "active"))
});
if (!plugin) {
console.error("❌ NAO6 Plugin not found.");
process.exit(1);
}
const actions = plugin.actionDefinitions as any[];
const requiredActions = ["nao_nod", "nao_shake_head", "nao_bow", "nao_open_hand"];
for (const actionId of requiredActions) {
const found = actions.find(a => a.id === actionId);
if (!found) {
console.error(`❌ Plugin missing action: ${actionId}`);
process.exit(1);
}
console.log(`✅ Plugin has action: ${actionId}`);
}
console.log("🎉 Verification Complete: Platform is ready for the study!");
process.exit(0);
}
verify().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -46,7 +46,10 @@ export default function DebugPage() {
const ROS_BRIDGE_URL = "ws://134.82.159.25:9090"; const ROS_BRIDGE_URL = "ws://134.82.159.25:9090";
const addLog = (message: string, type: "info" | "error" | "success" = "info") => { const addLog = (
message: string,
type: "info" | "error" | "success" = "info",
) => {
const timestamp = new Date().toLocaleTimeString(); const timestamp = new Date().toLocaleTimeString();
const logEntry = `[${timestamp}] [${type.toUpperCase()}] ${message}`; const logEntry = `[${timestamp}] [${type.toUpperCase()}] ${message}`;
setLogs((prev) => [...prev.slice(-99), logEntry]); setLogs((prev) => [...prev.slice(-99), logEntry]);
@@ -79,7 +82,9 @@ export default function DebugPage() {
setConnectionStatus("connecting"); setConnectionStatus("connecting");
setConnectionAttempts((prev) => prev + 1); setConnectionAttempts((prev) => prev + 1);
setLastError(null); setLastError(null);
addLog(`Attempting connection #${connectionAttempts + 1} to ${ROS_BRIDGE_URL}`); addLog(
`Attempting connection #${connectionAttempts + 1} to ${ROS_BRIDGE_URL}`,
);
const socket = new WebSocket(ROS_BRIDGE_URL); const socket = new WebSocket(ROS_BRIDGE_URL);
@@ -96,7 +101,10 @@ export default function DebugPage() {
setConnectionStatus("connected"); setConnectionStatus("connected");
setRosSocket(socket); setRosSocket(socket);
setLastError(null); setLastError(null);
addLog("✅ WebSocket connection established successfully", "success"); addLog(
"[SUCCESS] WebSocket connection established successfully",
"success",
);
// Test basic functionality by advertising // Test basic functionality by advertising
const advertiseMsg = { const advertiseMsg = {
@@ -138,16 +146,20 @@ export default function DebugPage() {
addLog(`Connection closed normally: ${event.reason || reason}`); addLog(`Connection closed normally: ${event.reason || reason}`);
} else if (event.code === 1006) { } else if (event.code === 1006) {
reason = "Connection lost/refused"; reason = "Connection lost/refused";
setLastError("ROS Bridge server not responding - check if rosbridge_server is running"); setLastError(
addLog(`❌ Connection failed: ${reason} (${event.code})`, "error"); "ROS Bridge server not responding - check if rosbridge_server is running",
);
addLog(`[ERROR] Connection failed: ${reason} (${event.code})`, "error");
} else if (event.code === 1011) { } else if (event.code === 1011) {
reason = "Server error"; reason = "Server error";
setLastError("ROS Bridge server encountered an error"); setLastError("ROS Bridge server encountered an error");
addLog(` Server error: ${reason} (${event.code})`, "error"); addLog(`[ERROR] Server error: ${reason} (${event.code})`, "error");
} else { } else {
reason = `Code ${event.code}`; reason = `Code ${event.code}`;
setLastError(`Connection closed with code ${event.code}: ${event.reason || "No reason given"}`); setLastError(
addLog(`Connection closed: ${reason}`, "error"); `Connection closed with code ${event.code}: ${event.reason || "No reason given"}`,
);
addLog(`[ERROR] Connection closed: ${reason}`, "error");
} }
if (wasConnected) { if (wasConnected) {
@@ -160,7 +172,7 @@ export default function DebugPage() {
setConnectionStatus("error"); setConnectionStatus("error");
const errorMsg = "WebSocket error occurred"; const errorMsg = "WebSocket error occurred";
setLastError(errorMsg); setLastError(errorMsg);
addLog(` ${errorMsg}`, "error"); addLog(`[ERROR] ${errorMsg}`, "error");
console.error("WebSocket error details:", error); console.error("WebSocket error details:", error);
}; };
}; };
@@ -298,7 +310,7 @@ export default function DebugPage() {
> >
{connectionStatus.toUpperCase()} {connectionStatus.toUpperCase()}
</Badge> </Badge>
<span className="text-sm text-muted-foreground"> <span className="text-muted-foreground text-sm">
Attempts: {connectionAttempts} Attempts: {connectionAttempts}
</span> </span>
</div> </div>
@@ -306,7 +318,9 @@ export default function DebugPage() {
{lastError && ( {lastError && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-sm">{lastError}</AlertDescription> <AlertDescription className="text-sm">
{lastError}
</AlertDescription>
</Alert> </Alert>
)} )}
@@ -318,7 +332,9 @@ export default function DebugPage() {
className="flex-1" className="flex-1"
> >
<Play className="mr-2 h-4 w-4" /> <Play className="mr-2 h-4 w-4" />
{connectionStatus === "connecting" ? "Connecting..." : "Connect"} {connectionStatus === "connecting"
? "Connecting..."
: "Connect"}
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -479,27 +495,32 @@ export default function DebugPage() {
key={index} key={index}
className={`rounded p-2 text-xs ${ className={`rounded p-2 text-xs ${
msg.direction === "sent" msg.direction === "sent"
? "bg-blue-50 border-l-2 border-blue-400" ? "border-l-2 border-blue-400 bg-blue-50"
: "bg-green-50 border-l-2 border-green-400" : "border-l-2 border-green-400 bg-green-50"
}`} }`}
> >
<div className="flex items-center justify-between mb-1"> <div className="mb-1 flex items-center justify-between">
<Badge <Badge
variant={msg.direction === "sent" ? "default" : "secondary"} variant={
msg.direction === "sent" ? "default" : "secondary"
}
className="text-xs" className="text-xs"
> >
{msg.direction === "sent" ? "SENT" : "RECEIVED"} {msg.direction === "sent" ? "SENT" : "RECEIVED"}
</Badge> </Badge>
<span className="text-muted-foreground">{msg.timestamp}</span> <span className="text-muted-foreground">
{msg.timestamp}
</span>
</div> </div>
<pre className="whitespace-pre-wrap text-xs"> <pre className="text-xs whitespace-pre-wrap">
{JSON.stringify(msg.data, null, 2)} {JSON.stringify(msg.data, null, 2)}
</pre> </pre>
</div> </div>
))} ))}
{messages.length === 0 && ( {messages.length === 0 && (
<div className="text-center text-muted-foreground py-8"> <div className="text-muted-foreground py-8 text-center">
No messages yet. Connect and send a test message to see data here. No messages yet. Connect and send a test message to see data
here.
</div> </div>
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />

View File

@@ -0,0 +1,146 @@
import {
BookOpen,
FlaskConical,
PlayCircle,
BarChart3,
HelpCircle,
FileText,
Video,
ExternalLink,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { PageLayout } from "~/components/ui/page-layout";
import Link from "next/link";
export default function HelpCenterPage() {
const guides = [
{
title: "Getting Started",
description: "Learn the basics of HRIStudio and set up your first study.",
icon: BookOpen,
items: [
{ label: "Platform Overview", href: "#" },
{ label: "Creating a New Study", href: "#" },
{ label: "Managing Team Members", href: "#" },
],
},
{
title: "Designing Experiments",
description: "Master the visual experiment designer and flow control.",
icon: FlaskConical,
items: [
{ label: "Using the Visual Designer", href: "#" },
{ label: "Robot Actions & Plugins", href: "#" },
{ label: "Variables & Logic", href: "#" },
],
},
{
title: "Running Trials",
description: "Execute experiments and manage Wizard of Oz sessions.",
icon: PlayCircle,
items: [
{ label: "Wizard Interface Guide", href: "#" },
{ label: "Participant Management", href: "#" },
{ label: "Handling Robot Errors", href: "#" },
],
},
{
title: "Analysis & Data",
description: "Analyze trial results and export research data.",
icon: BarChart3,
items: [
{ label: "Understanding Analytics", href: "#" },
{ label: "Exporting Data (CSV/JSON)", href: "#" },
{ label: "Video Replay & Annotation", href: "#" },
],
},
];
return (
<PageLayout
title="Help Center"
description="Documentation, guides, and support for HRIStudio researchers."
>
<div className="grid gap-6 md:grid-cols-2">
{guides.map((guide, index) => (
<Card key={index}>
<CardHeader>
<div className="flex items-center gap-2">
<div className="bg-primary/10 rounded-lg p-2">
<guide.icon className="text-primary h-5 w-5" />
</div>
<CardTitle className="text-xl">{guide.title}</CardTitle>
</div>
<CardDescription>{guide.description}</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{guide.items.map((item, i) => (
<li key={i}>
<Button
variant="link"
className="text-foreground hover:text-primary h-auto justify-start p-0 font-normal"
asChild
>
<Link href={item.href}>
<FileText className="text-muted-foreground mr-2 h-4 w-4" />
{item.label}
</Link>
</Button>
</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
<div className="mt-8">
<h2 className="mb-4 text-2xl font-bold tracking-tight">
Video Tutorials
</h2>
<div className="grid gap-6 md:grid-cols-3">
{[
"Introduction to HRIStudio",
"Advanced Flow Control",
"ROS2 Integration Deep Dive",
].map((title, i) => (
<Card key={i} className="overflow-hidden">
<div className="bg-muted group hover:bg-muted/80 relative flex aspect-video cursor-pointer items-center justify-center transition-colors">
<PlayCircle className="text-muted-foreground group-hover:text-primary h-12 w-12 transition-colors" />
</div>
<CardHeader className="p-4">
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
</Card>
))}
</div>
</div>
<div className="bg-muted/50 mt-8 rounded-xl border p-8 text-center">
<div className="bg-background mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full shadow-sm">
<HelpCircle className="text-primary h-6 w-6" />
</div>
<h2 className="mb-2 text-xl font-semibold">Still need help?</h2>
<p className="text-muted-foreground mx-auto mb-6 max-w-md">
Contact your system administrator or check the official documentation
for technical support.
</p>
<div className="flex justify-center gap-4">
<Button variant="outline" className="gap-2">
<ExternalLink className="h-4 w-4" />
Official Docs
</Button>
<Button className="gap-2">Contact Support</Button>
</div>
</div>
</PageLayout>
);
}

View File

@@ -365,7 +365,9 @@ export default function NaoTestPage() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Walk Speed: {(walkSpeed[0] ?? 0).toFixed(2)} m/s</Label> <Label>
Walk Speed: {(walkSpeed[0] ?? 0).toFixed(2)} m/s
</Label>
<Slider <Slider
value={walkSpeed} value={walkSpeed}
onValueChange={setWalkSpeed} onValueChange={setWalkSpeed}
@@ -375,7 +377,9 @@ export default function NaoTestPage() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Turn Speed: {(turnSpeed[0] ?? 0).toFixed(2)} rad/s</Label> <Label>
Turn Speed: {(turnSpeed[0] ?? 0).toFixed(2)} rad/s
</Label>
<Slider <Slider
value={turnSpeed} value={turnSpeed}
onValueChange={setTurnSpeed} onValueChange={setTurnSpeed}
@@ -415,7 +419,9 @@ export default function NaoTestPage() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Head Yaw: {(headYaw[0] ?? 0).toFixed(2)} rad</Label> <Label>
Head Yaw: {(headYaw[0] ?? 0).toFixed(2)} rad
</Label>
<Slider <Slider
value={headYaw} value={headYaw}
onValueChange={setHeadYaw} onValueChange={setHeadYaw}
@@ -425,7 +431,9 @@ export default function NaoTestPage() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Head Pitch: {(headPitch[0] ?? 0).toFixed(2)} rad</Label> <Label>
Head Pitch: {(headPitch[0] ?? 0).toFixed(2)} rad
</Label>
<Slider <Slider
value={headPitch} value={headPitch}
onValueChange={setHeadPitch} onValueChange={setHeadPitch}

View File

@@ -16,8 +16,19 @@ import { Separator } from "~/components/ui/separator";
import { PageHeader } from "~/components/ui/page-header"; import { PageHeader } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { formatRole, getRoleDescription } from "~/lib/auth-client"; import { formatRole, getRoleDescription } from "~/lib/auth-client";
import { User, Shield, Download, Trash2, ExternalLink } from "lucide-react"; import {
User,
Shield,
Download,
Trash2,
ExternalLink,
Lock,
UserCog,
Mail,
Fingerprint,
} from "lucide-react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { cn } from "~/lib/utils";
interface ProfileUser { interface ProfileUser {
id: string; id: string;
@@ -32,185 +43,169 @@ interface ProfileUser {
function ProfileContent({ user }: { user: ProfileUser }) { function ProfileContent({ user }: { user: ProfileUser }) {
return ( return (
<div className="space-y-6"> <div className="animate-in fade-in space-y-8 duration-500">
<PageHeader <PageHeader
title="Profile" title={user.name ?? "User"}
description="Manage your account settings and preferences" description={user.email}
icon={User} icon={User}
badges={[
{ label: `ID: ${user.id}`, variant: "outline" },
...(user.roles?.map((r) => ({
label: formatRole(r.role),
variant: "secondary" as const,
})) ?? []),
]}
/> />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* Profile Information */} {/* Main Content (Left Column) */}
<div className="space-y-6 lg:col-span-2"> <div className="space-y-8 lg:col-span-2">
{/* Basic Information */} {/* Personal Information */}
<Card> <section className="space-y-4">
<CardHeader> <div className="flex items-center gap-2 border-b pb-2">
<CardTitle>Basic Information</CardTitle> <User className="text-primary h-5 w-5" />
<CardDescription> <h3 className="text-lg font-semibold">Personal Information</h3>
Your personal account information </div>
</CardDescription> <Card className="border-border/60 hover:border-border transition-colors">
</CardHeader> <CardHeader>
<CardContent> <CardTitle className="text-base">Contact Details</CardTitle>
<ProfileEditForm <CardDescription>
user={{ Update your public profile information
id: user.id, </CardDescription>
name: user.name, </CardHeader>
email: user.email, <CardContent>
image: user.image, <ProfileEditForm
}} user={{
/> id: user.id,
</CardContent> name: user.name,
</Card> email: user.email,
image: user.image,
}}
/>
</CardContent>
</Card>
</section>
{/* Password Change */} {/* Security */}
<Card> <section className="space-y-4">
<CardHeader> <div className="flex items-center gap-2 border-b pb-2">
<CardTitle>Password</CardTitle> <Lock className="text-primary h-5 w-5" />
<CardDescription>Change your account password</CardDescription> <h3 className="text-lg font-semibold">Security</h3>
</CardHeader> </div>
<CardContent> <Card className="border-border/60 hover:border-border transition-colors">
<PasswordChangeForm /> <CardHeader>
</CardContent> <CardTitle className="text-base">Password</CardTitle>
</Card> <CardDescription>
Ensure your account stays secure
{/* Account Actions */} </CardDescription>
<Card> </CardHeader>
<CardHeader> <CardContent>
<CardTitle>Account Actions</CardTitle> <PasswordChangeForm />
<CardDescription>Manage your account settings</CardDescription> </CardContent>
</CardHeader> </Card>
<CardContent className="space-y-4"> </section>
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium">Export Data</h4>
<p className="text-muted-foreground text-sm">
Download all your research data and account information
</p>
</div>
<Button variant="outline" disabled>
<Download className="mr-2 h-4 w-4" />
Export Data
</Button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<h4 className="text-destructive text-sm font-medium">
Delete Account
</h4>
<p className="text-muted-foreground text-sm">
Permanently delete your account and all associated data
</p>
</div>
<Button variant="destructive" disabled>
<Trash2 className="mr-2 h-4 w-4" />
Delete Account
</Button>
</div>
</CardContent>
</Card>
</div> </div>
{/* Sidebar */} {/* Sidebar (Right Column) */}
<div className="space-y-6"> <div className="space-y-8">
{/* User Summary */} {/* Permissions */}
<Card> <section className="space-y-4">
<CardHeader> <div className="flex items-center gap-2 border-b pb-2">
<CardTitle>Account Summary</CardTitle> <Shield className="text-primary h-5 w-5" />
</CardHeader> <h3 className="text-lg font-semibold">Permissions</h3>
<CardContent className="space-y-4"> </div>
<div className="flex items-center space-x-3"> <Card>
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-full"> <CardContent className="pt-6">
<span className="text-primary text-lg font-semibold"> {user.roles && user.roles.length > 0 ? (
{(user.name ?? user.email ?? "U").charAt(0).toUpperCase()} <div className="space-y-4">
</span> {user.roles.map((roleInfo, index) => (
</div> <div key={index} className="space-y-2">
<div> <div className="flex items-center justify-between">
<p className="font-medium">{user.name ?? "Unnamed User"}</p> <span className="text-sm font-medium">
<p className="text-muted-foreground text-sm">{user.email}</p>
</div>
</div>
<Separator />
<div>
<p className="mb-2 text-sm font-medium">User ID</p>
<p className="text-muted-foreground bg-muted rounded p-2 font-mono text-xs break-all">
{user.id}
</p>
</div>
</CardContent>
</Card>
{/* System Roles */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-4 w-4" />
System Roles
</CardTitle>
<CardDescription>Your current system permissions</CardDescription>
</CardHeader>
<CardContent>
{user.roles && user.roles.length > 0 ? (
<div className="space-y-3">
{user.roles.map((roleInfo, index: number) => (
<div
key={index}
className="flex items-start justify-between"
>
<div className="flex-1">
<div className="mb-1 flex items-center gap-2">
<Badge variant="secondary">
{formatRole(roleInfo.role)} {formatRole(roleInfo.role)}
</Badge> </span>
<span className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px]">
Since{" "}
{new Date(roleInfo.grantedAt).toLocaleDateString()}
</span>
</div> </div>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs leading-relaxed">
{getRoleDescription(roleInfo.role)} {getRoleDescription(roleInfo.role)}
</p> </p>
<p className="text-muted-foreground/80 mt-1 text-xs"> {index < (user.roles?.length || 0) - 1 && (
Granted{" "} <Separator className="my-2" />
{new Date(roleInfo.grantedAt).toLocaleDateString()} )}
</p>
</div> </div>
))}
<div className="text-muted-foreground mt-4 rounded-lg border border-blue-100 bg-blue-50/50 p-3 text-xs dark:border-blue-900/30 dark:bg-blue-900/10">
<div className="text-primary mb-1 flex items-center gap-2 font-medium">
<Shield className="h-3 w-3" />
<span>Role Management</span>
</div>
System roles are managed by administrators. Contact
support if you need access adjustments.
</div> </div>
))} </div>
) : (
<Separator /> <div className="py-4 text-center">
<p className="text-sm font-medium">No Roles Assigned</p>
<div className="text-center"> <p className="text-muted-foreground mt-1 text-xs">
<p className="text-muted-foreground text-xs"> Contact an admin to request access.
Need additional permissions?{" "}
<Button
variant="link"
size="sm"
className="h-auto p-0 text-xs"
>
Contact an administrator
<ExternalLink className="ml-1 h-3 w-3" />
</Button>
</p> </p>
<Button size="sm" variant="outline" className="mt-3 w-full">
Request Access
</Button>
</div> </div>
</div> )}
) : ( </CardContent>
<div className="py-6 text-center"> </Card>
<div className="bg-muted mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-lg"> </section>
<Shield className="text-muted-foreground h-6 w-6" />
</div> {/* Data & Privacy */}
<p className="mb-1 text-sm font-medium">No Roles Assigned</p> <section className="space-y-4">
<p className="text-muted-foreground text-xs"> <div className="flex items-center gap-2 border-b pb-2">
You don&apos;t have any system roles yet. Contact an <Download className="text-primary h-5 w-5" />
administrator to get access to HRIStudio features. <h3 className="text-lg font-semibold">Data & Privacy</h3>
</div>
<Card className="border-destructive/10 bg-destructive/5 overflow-hidden">
<CardContent className="space-y-4 pt-6">
<div>
<h4 className="mb-1 text-sm font-semibold">Export Data</h4>
<p className="text-muted-foreground mb-3 text-xs">
Download a copy of your personal data.
</p> </p>
<Button size="sm" variant="outline"> <Button
Request Access variant="outline"
size="sm"
className="bg-background w-full"
disabled
>
<Download className="mr-2 h-3 w-3" />
Download Archive
</Button> </Button>
</div> </div>
)} <Separator className="bg-destructive/10" />
</CardContent> <div>
</Card> <h4 className="text-destructive mb-1 text-sm font-semibold">
Delete Account
</h4>
<p className="text-muted-foreground mb-3 text-xs">
This action is irreversible.
</p>
<Button
variant="destructive"
size="sm"
className="w-full"
disabled
>
<Trash2 className="mr-2 h-3 w-3" />
Delete Account
</Button>
</div>
</CardContent>
</Card>
</section>
</div> </div>
</div> </div>
</div> </div>
@@ -218,13 +213,21 @@ function ProfileContent({ user }: { user: ProfileUser }) {
} }
export default function ProfilePage() { export default function ProfilePage() {
const { data: session } = useSession(); const { data: session, status } = useSession();
useBreadcrumbsEffect([ useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" }, { label: "Dashboard", href: "/dashboard" },
{ label: "Profile" }, { label: "Profile" },
]); ]);
if (status === "loading") {
return (
<div className="text-muted-foreground animate-pulse p-8">
Loading profile...
</div>
);
}
if (!session?.user) { if (!session?.user) {
redirect("/auth/signin"); redirect("/auth/signin");
} }

View File

@@ -1,190 +1,15 @@
"use client"; "use client";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect } from "react";
import { import { BarChart3 } from "lucide-react";
BarChart3,
Search,
Filter,
PlayCircle,
Calendar,
Clock,
ChevronRight,
User,
LayoutGrid
} from "lucide-react";
import { PageHeader } from "~/components/ui/page-header"; import { PageHeader } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useStudyContext } from "~/lib/study-context"; import { useStudyContext } from "~/lib/study-context";
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails"; import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { TrialAnalysisView } from "~/components/trials/views/TrialAnalysisView"; import { StudyAnalyticsDataTable } from "~/components/analytics/study-analytics-data-table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { ScrollArea } from "~/components/ui/scroll-area";
import { formatDistanceToNow } from "date-fns";
// -- Sub-Components --
function AnalyticsContent({
selectedTrialId,
setSelectedTrialId,
trialsList,
isLoadingList
}: {
selectedTrialId: string | null;
setSelectedTrialId: (id: string | null) => void;
trialsList: any[];
isLoadingList: boolean;
}) {
// Fetch full details of selected trial
const {
data: selectedTrial,
isLoading: isLoadingTrial,
error: trialError
} = api.trials.get.useQuery(
{ id: selectedTrialId! },
{ enabled: !!selectedTrialId }
);
// Transform trial data
const trialData = selectedTrial ? {
...selectedTrial,
startedAt: selectedTrial.startedAt ? new Date(selectedTrial.startedAt) : null,
completedAt: selectedTrial.completedAt ? new Date(selectedTrial.completedAt) : null,
eventCount: (selectedTrial as any).eventCount,
mediaCount: (selectedTrial as any).mediaCount,
} : null;
return (
<div className="h-[calc(100vh-140px)] flex flex-col">
{selectedTrialId ? (
isLoadingTrial ? (
<div className="flex-1 flex items-center justify-center bg-background/50 rounded-lg border border-dashed">
<div className="flex flex-col items-center gap-2 animate-pulse">
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
<span className="text-muted-foreground text-sm">Loading trial data...</span>
</div>
</div>
) : trialError ? (
<div className="flex-1 flex items-center justify-center p-8 bg-background/50 rounded-lg border border-dashed text-destructive">
<div className="max-w-md text-center">
<h3 className="font-semibold mb-2">Error Loading Trial</h3>
<p className="text-sm opacity-80">{trialError.message}</p>
<Button variant="outline" className="mt-4" onClick={() => setSelectedTrialId(null)}>
Return to Overview
</Button>
</div>
</div>
) : trialData ? (
<TrialAnalysisView trial={trialData} />
) : null
) : (
<div className="flex-1 bg-background/50 rounded-lg border shadow-sm overflow-hidden">
<StudyOverviewPlaceholder
trials={trialsList ?? []}
onSelect={(id) => setSelectedTrialId(id)}
/>
</div>
)}
</div>
);
}
function StudyOverviewPlaceholder({ trials, onSelect }: { trials: any[], onSelect: (id: string) => void }) {
const recentTrials = [...trials].sort((a, b) =>
new Date(b.startedAt || b.createdAt).getTime() - new Date(a.startedAt || a.createdAt).getTime()
).slice(0, 5);
return (
<div className="h-full p-8 grid place-items-center bg-muted/5">
<div className="max-w-3xl w-full grid gap-8 md:grid-cols-2">
{/* Left: Illustration / Prompt */}
<div className="flex flex-col justify-center space-y-4">
<div className="bg-primary/10 w-16 h-16 rounded-2xl flex items-center justify-center mb-2">
<BarChart3 className="h-8 w-8 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold tracking-tight">Analytics & Playback</h2>
<CardDescription className="text-base mt-2">
Select a session from the top right to review video recordings, event logs, and metrics.
</CardDescription>
</div>
<div className="flex gap-4 pt-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<PlayCircle className="h-4 w-4" />
Feature-rich playback
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
Synchronized timeline
</div>
</div>
</div>
{/* Right: Recent Sessions */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Recent Sessions</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="h-[240px]">
<div className="px-4 pb-4 space-y-1">
{recentTrials.map(trial => (
<button
key={trial.id}
onClick={() => onSelect(trial.id)}
className="w-full flex items-center gap-3 p-3 rounded-md hover:bg-accent transition-colors text-left group"
>
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-mono font-medium text-primary">
{trial.sessionNumber}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{trial.participant?.participantCode ?? "Unknown"}
</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full border capitalize ${trial.status === 'completed' ? 'bg-green-500/10 text-green-500 border-green-500/20' :
trial.status === 'in_progress' ? 'bg-blue-500/10 text-blue-500 border-blue-500/20' :
'bg-slate-500/10 text-slate-500 border-slate-500/20'
}`}>
{trial.status.replace('_', ' ')}
</span>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-2 mt-0.5">
<Calendar className="h-3 w-3" />
{new Date(trial.createdAt).toLocaleDateString()}
<span className="text-muted-foreground top-[1px] relative text-[10px]"></span>
{formatDistanceToNow(new Date(trial.createdAt), { addSuffix: true })}
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground/30 group-hover:text-primary transition-colors" />
</button>
))}
{recentTrials.length === 0 && (
<div className="py-8 text-center text-sm text-muted-foreground">
No sessions found.
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
)
}
// -- Main Page --
export default function StudyAnalyticsPage() { export default function StudyAnalyticsPage() {
const params = useParams(); const params = useParams();
@@ -192,13 +17,10 @@ export default function StudyAnalyticsPage() {
const { setSelectedStudyId, selectedStudyId } = useStudyContext(); const { setSelectedStudyId, selectedStudyId } = useStudyContext();
const { study } = useSelectedStudyDetails(); const { study } = useSelectedStudyDetails();
// State lifted up // Fetch list of trials
const [selectedTrialId, setSelectedTrialId] = useState<string | null>(null); const { data: trialsList, isLoading } = api.trials.list.useQuery(
// Fetch list of trials for the selector
const { data: trialsList, isLoading: isLoadingList } = api.trials.list.useQuery(
{ studyId, limit: 100 }, { studyId, limit: 100 },
{ enabled: !!studyId } { enabled: !!studyId },
); );
// Set breadcrumbs // Set breadcrumbs
@@ -217,50 +39,34 @@ export default function StudyAnalyticsPage() {
}, [studyId, selectedStudyId, setSelectedStudyId]); }, [studyId, selectedStudyId, setSelectedStudyId]);
return ( return (
<div className="h-[calc(100vh-64px)] flex flex-col p-6 gap-6"> <div className="space-y-6">
<div className="flex-none"> <PageHeader
<PageHeader title="Analysis"
title="Analytics" description="View and analyze session data across all trials"
description="Analyze trial data and replay sessions" icon={BarChart3}
icon={BarChart3} />
actions={
<div className="flex items-center gap-2"> <div className="bg-transparent">
{/* Session Selector in Header */} <Suspense fallback={<div>Loading analytics...</div>}>
<div className="w-[300px]"> {isLoading ? (
<Select <div className="flex h-64 items-center justify-center">
value={selectedTrialId ?? "overview"} <div className="flex animate-pulse flex-col items-center gap-2">
onValueChange={(val) => setSelectedTrialId(val === "overview" ? null : val)} <div className="border-primary h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
> <span className="text-muted-foreground text-sm">
<SelectTrigger className="w-full h-9 text-xs"> Loading session data...
<LayoutGrid className="h-3.5 w-3.5 mr-2 text-muted-foreground" /> </span>
<SelectValue placeholder="Select Session" />
</SelectTrigger>
<SelectContent className="max-h-[400px]" align="end">
<SelectItem value="overview" className="border-b mb-1 pb-1 font-medium text-xs">
Show Study Overview
</SelectItem>
{trialsList?.map((trial) => (
<SelectItem key={trial.id} value={trial.id} className="text-xs">
<span className="font-mono mr-2 text-muted-foreground">#{trial.sessionNumber}</span>
{trial.participant?.participantCode ?? "Unknown"} <span className="text-muted-foreground ml-1">({new Date(trial.createdAt).toLocaleDateString()})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div> </div>
} ) : (
/> <StudyAnalyticsDataTable
</div> data={(trialsList ?? []).map((t) => ({
...t,
<div className="flex-1 min-h-0 bg-transparent"> startedAt: t.startedAt ? new Date(t.startedAt) : null,
<Suspense fallback={<div>Loading analytics...</div>}> completedAt: t.completedAt ? new Date(t.completedAt) : null,
<AnalyticsContent createdAt: new Date(t.createdAt),
selectedTrialId={selectedTrialId} }))}
setSelectedTrialId={setSelectedTrialId} />
trialsList={trialsList ?? []} )}
isLoadingList={isLoadingList}
/>
</Suspense> </Suspense>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,8 @@
"use client"; "use client";
import { useMemo } from "react";
import { DesignerRoot } from "~/components/experiments/designer/DesignerRoot"; import { DesignerRoot } from "~/components/experiments/designer/DesignerRoot";
import { useActionRegistry } from "~/components/experiments/designer/ActionRegistry";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import type { ExperimentStep } from "~/lib/experiment-designer/types"; import type { ExperimentStep } from "~/lib/experiment-designer/types";
@@ -9,6 +11,10 @@ interface DesignerPageClientProps {
id: string; id: string;
name: string; name: string;
description: string | null; description: string | null;
status: string;
studyId: string;
createdAt: Date;
updatedAt: Date;
study: { study: {
id: string; id: string;
name: string; name: string;
@@ -28,6 +34,22 @@ export function DesignerPageClient({
experiment, experiment,
initialDesign, initialDesign,
}: DesignerPageClientProps) { }: DesignerPageClientProps) {
// Initialize action registry early to prevent CLS
useActionRegistry();
// Calculate design statistics
const designStats = useMemo(() => {
if (!initialDesign) return undefined;
const stepCount = initialDesign.steps.length;
const actionCount = initialDesign.steps.reduce(
(sum, step) => sum + step.actions.length,
0,
);
return { stepCount, actionCount };
}, [initialDesign]);
// Set breadcrumbs // Set breadcrumbs
useBreadcrumbsEffect([ useBreadcrumbsEffect([
{ {
@@ -56,6 +78,11 @@ export function DesignerPageClient({
]); ]);
return ( return (
<DesignerRoot experimentId={experiment.id} initialDesign={initialDesign} /> <DesignerRoot
experimentId={experiment.id}
initialDesign={initialDesign}
experiment={experiment}
designStats={designStats}
/>
); );
} }

View File

@@ -20,7 +20,9 @@ export default async function ExperimentDesignerPage({
}: ExperimentDesignerPageProps) { }: ExperimentDesignerPageProps) {
try { try {
const resolvedParams = await params; const resolvedParams = await params;
const experiment = await api.experiments.get({ id: resolvedParams.experimentId }); const experiment = await api.experiments.get({
id: resolvedParams.experimentId,
});
if (!experiment) { if (!experiment) {
notFound(); notFound();
@@ -36,13 +38,13 @@ export default async function ExperimentDesignerPage({
// Only pass initialDesign if there's existing visual design data // Only pass initialDesign if there's existing visual design data
let initialDesign: let initialDesign:
| { | {
id: string; id: string;
name: string; name: string;
description: string; description: string;
steps: ExperimentStep[]; steps: ExperimentStep[];
version: number; version: number;
lastSaved: Date; lastSaved: Date;
} }
| undefined; | undefined;
if (existingDesign?.steps && existingDesign.steps.length > 0) { if (existingDesign?.steps && existingDesign.steps.length > 0) {
@@ -121,7 +123,8 @@ export default async function ExperimentDesignerPage({
}; };
}); });
const mapped: ExperimentStep[] = exec.steps.map((s, idx) => { const mapped: ExperimentStep[] = exec.steps.map((s, idx) => {
const actions: ExperimentAction[] = s.actions.map((a) => { // Recursive function to hydrate actions with children
const hydrateAction = (a: any): ExperimentAction => {
// Normalize legacy plugin action ids and provenance // Normalize legacy plugin action ids and provenance
const rawType = a.type ?? ""; const rawType = a.type ?? "";
@@ -188,11 +191,24 @@ export default async function ExperimentDesignerPage({
const pluginId = legacy?.pluginId; const pluginId = legacy?.pluginId;
const pluginVersion = legacy?.pluginVersion; const pluginVersion = legacy?.pluginVersion;
// Extract children from parameters for control flow actions
const params = (a.parameters ?? {}) as Record<string, unknown>;
let children: ExperimentAction[] | undefined = undefined;
// Handle control flow structures (sequence, parallel, loop only)
// Branch actions control step routing, not nested actions
const childrenRaw = params.children;
// Recursively hydrate nested children for container actions
if (Array.isArray(childrenRaw) && childrenRaw.length > 0) {
children = childrenRaw.map((child: any) => hydrateAction(child));
}
return { return {
id: a.id, id: a.id,
type: typeOut, type: typeOut,
name: a.name, name: a.name,
parameters: (a.parameters ?? {}) as Record<string, unknown>, parameters: params,
category: categoryOut, category: categoryOut,
source: { source: {
kind: sourceKind, kind: sourceKind,
@@ -202,8 +218,13 @@ export default async function ExperimentDesignerPage({
baseActionId: legacy?.baseId, baseActionId: legacy?.baseId,
}, },
execution, execution,
children, // Add children at top level
}; };
}); };
const actions: ExperimentAction[] = s.actions.map((a) =>
hydrateAction(a),
);
return { return {
id: s.id, id: s.id,
name: s.name, name: s.name,
@@ -222,7 +243,10 @@ export default async function ExperimentDesignerPage({
: "sequential"; : "sequential";
})(), })(),
order: s.orderIndex ?? idx, order: s.orderIndex ?? idx,
trigger: { type: "trial_start", conditions: {} }, trigger: {
type: idx === 0 ? "trial_start" : "previous_step",
conditions: (s.conditions as Record<string, unknown>) || {},
},
actions, actions,
expanded: true, expanded: true,
}; };
@@ -258,7 +282,9 @@ export async function generateMetadata({
}> { }> {
try { try {
const resolvedParams = await params; const resolvedParams = await params;
const experiment = await api.experiments.get({ id: resolvedParams.experimentId }); const experiment = await api.experiments.get({
id: resolvedParams.experimentId,
});
return { return {
title: `${experiment?.name} - Designer | HRIStudio`, title: `${experiment?.name} - Designer | HRIStudio`,

View File

@@ -1,164 +0,0 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "~/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { toast } from "sonner";
import { api } from "~/trpc/react";
import { useRouter } from "next/navigation";
import { type experiments, experimentStatusEnum } from "~/server/db/schema";
import { type InferSelectModel } from "drizzle-orm";
type Experiment = InferSelectModel<typeof experiments>;
const formSchema = z.object({
name: z.string().min(2, {
message: "Name must be at least 2 characters.",
}),
description: z.string().optional(),
status: z.enum(experimentStatusEnum.enumValues),
});
interface ExperimentFormProps {
experiment: Experiment;
}
export function ExperimentForm({ experiment }: ExperimentFormProps) {
const router = useRouter();
const updateExperiment = api.experiments.update.useMutation({
onSuccess: () => {
toast.success("Experiment updated successfully");
router.refresh();
router.push(`/studies/${experiment.studyId}/experiments/${experiment.id}`);
},
onError: (error) => {
toast.error(`Error updating experiment: ${error.message}`);
},
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: experiment.name,
description: experiment.description ?? "",
status: experiment.status,
},
});
function onSubmit(values: z.infer<typeof formSchema>) {
updateExperiment.mutate({
id: experiment.id,
name: values.name,
description: values.description,
status: values.status,
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Experiment name" {...field} />
</FormControl>
<FormDescription>
The name of your experiment.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Describe your experiment..."
className="resize-none"
{...field}
/>
</FormControl>
<FormDescription>
A short description of the experiment goals.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="testing">Testing</SelectItem>
<SelectItem value="ready">Ready</SelectItem>
<SelectItem value="deprecated">Deprecated</SelectItem>
</SelectContent>
</Select>
<FormDescription>
The current status of the experiment.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-4">
<Button type="submit" disabled={updateExperiment.isPending}>
{updateExperiment.isPending ? "Saving..." : "Save Changes"}
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
router.push(
`/studies/${experiment.studyId}/experiments/${experiment.id}`,
)
}
>
Cancel
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -1,58 +0,0 @@
import { notFound } from "next/navigation";
import { type experiments } from "~/server/db/schema";
import { type InferSelectModel } from "drizzle-orm";
type Experiment = InferSelectModel<typeof experiments>;
import { api } from "~/trpc/server";
import { ExperimentForm } from "./experiment-form";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
} from "~/components/ui/entity-view";
import { Button } from "~/components/ui/button";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
interface ExperimentEditPageProps {
params: Promise<{ id: string; experimentId: string }>;
}
export default async function ExperimentEditPage({
params,
}: ExperimentEditPageProps) {
const { id: studyId, experimentId } = await params;
const experiment = await api.experiments.get({ id: experimentId });
if (!experiment) {
notFound();
}
// Ensure experiment belongs to study
if (experiment.studyId !== studyId) {
notFound();
}
// Convert to type expected by form
const experimentData: Experiment = {
...experiment,
status: experiment.status as Experiment["status"],
};
return (
<EntityView>
<EntityViewHeader
title="Edit Experiment"
subtitle={`Update settings for ${experiment.name}`}
icon="Edit"
/>
<div className="max-w-2xl">
<EntityViewSection title="Experiment Details" icon="Settings">
<ExperimentForm experiment={experimentData} />
</EntityViewSection>
</div>
</EntityView>
);
}

View File

@@ -1,20 +1,29 @@
"use client"; "use client";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { Calendar, Clock, Edit, Play, Settings, Users } from "lucide-react"; import {
Calendar,
Clock,
Edit,
Play,
Settings,
Users,
TestTube,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { PageHeader } from "~/components/ui/page-header";
import { import {
EntityView, EntityView,
EntityViewHeader, EntityViewHeader,
EntityViewSection, EntityViewSection,
EmptyState, EmptyState,
InfoGrid, InfoGrid,
QuickActions, QuickActions,
StatsGrid, StatsGrid,
} from "~/components/ui/entity-view"; } from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
@@ -22,447 +31,443 @@ import { useSession } from "next-auth/react";
import { useStudyManagement } from "~/hooks/useStudyManagement"; import { useStudyManagement } from "~/hooks/useStudyManagement";
interface ExperimentDetailPageProps { interface ExperimentDetailPageProps {
params: Promise<{ id: string; experimentId: string }>; params: Promise<{ id: string; experimentId: string }>;
} }
const statusConfig = { const statusConfig = {
draft: { draft: {
label: "Draft", label: "Draft",
variant: "secondary" as const, variant: "secondary" as const,
icon: "FileText" as const, icon: "FileText" as const,
}, },
testing: { testing: {
label: "Testing", label: "Testing",
variant: "outline" as const, variant: "outline" as const,
icon: "TestTube" as const, icon: "TestTube" as const,
}, },
ready: { ready: {
label: "Ready", label: "Ready",
variant: "default" as const, variant: "default" as const,
icon: "CheckCircle" as const, icon: "CheckCircle" as const,
}, },
deprecated: { deprecated: {
label: "Deprecated", label: "Deprecated",
variant: "destructive" as const, variant: "destructive" as const,
icon: "AlertTriangle" as const, icon: "AlertTriangle" as const,
}, },
}; };
type Experiment = { type Experiment = {
id: string; id: string;
name: string; name: string;
description: string | null; description: string | null;
status: string; status: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
study: { id: string; name: string }; study: { id: string; name: string };
robot: { id: string; name: string; description: string | null } | null; robot: { id: string; name: string; description: string | null } | null;
protocol?: { blocks: unknown[] } | null; protocol?: { blocks: unknown[] } | null;
visualDesign?: unknown; visualDesign?: unknown;
studyId: string; studyId: string;
createdBy: string; createdBy: string;
robotId: string | null; robotId: string | null;
version: number; version: number;
}; };
type Trial = { type Trial = {
id: string;
status: string;
createdAt: Date;
duration: number | null;
participant: {
id: string; id: string;
status: string; participantCode: string;
createdAt: Date; name?: string | null;
duration: number | null; } | null;
participant: { experiment: { name: string } | null;
id: string; participantId: string | null;
participantCode: string; experimentId: string;
name?: string | null; startedAt: Date | null;
} | null; completedAt: Date | null;
experiment: { name: string } | null; notes: string | null;
participantId: string | null; updatedAt: Date;
experimentId: string; canAccess: boolean;
startedAt: Date | null; userRole: string;
completedAt: Date | null;
notes: string | null;
updatedAt: Date;
canAccess: boolean;
userRole: string;
}; };
export default function ExperimentDetailPage({ export default function ExperimentDetailPage({
params, params,
}: ExperimentDetailPageProps) { }: ExperimentDetailPageProps) {
const { data: session } = useSession(); const { data: session } = useSession();
const [experiment, setExperiment] = useState<Experiment | null>(null); const [experiment, setExperiment] = useState<Experiment | null>(null);
const [trials, setTrials] = useState<Trial[]>([]); const [trials, setTrials] = useState<Trial[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [resolvedParams, setResolvedParams] = useState<{ id: string; experimentId: string } | null>( const [resolvedParams, setResolvedParams] = useState<{
null, id: string;
); experimentId: string;
const { selectStudy } = useStudyManagement(); } | null>(null);
const { selectStudy } = useStudyManagement();
useEffect(() => { useEffect(() => {
const resolveParams = async () => { const resolveParams = async () => {
const resolved = await params; const resolved = await params;
setResolvedParams(resolved); setResolvedParams(resolved);
// Ensure study context is synced // Ensure study context is synced
if (resolved.id) { if (resolved.id) {
void selectStudy(resolved.id); void selectStudy(resolved.id);
} }
}; };
void resolveParams(); void resolveParams();
}, [params, selectStudy]); }, [params, selectStudy]);
const experimentQuery = api.experiments.get.useQuery( const experimentQuery = api.experiments.get.useQuery(
{ id: resolvedParams?.experimentId ?? "" }, { id: resolvedParams?.experimentId ?? "" },
{ enabled: !!resolvedParams?.experimentId }, { enabled: !!resolvedParams?.experimentId },
); );
const trialsQuery = api.trials.list.useQuery( const trialsQuery = api.trials.list.useQuery(
{ experimentId: resolvedParams?.experimentId ?? "" }, { experimentId: resolvedParams?.experimentId ?? "" },
{ enabled: !!resolvedParams?.experimentId }, { enabled: !!resolvedParams?.experimentId },
); );
useEffect(() => { useEffect(() => {
if (experimentQuery.data) { if (experimentQuery.data) {
setExperiment(experimentQuery.data); setExperiment(experimentQuery.data);
} }
}, [experimentQuery.data]); }, [experimentQuery.data]);
useEffect(() => { useEffect(() => {
if (trialsQuery.data) { if (trialsQuery.data) {
setTrials(trialsQuery.data); setTrials(trialsQuery.data);
} }
}, [trialsQuery.data]); }, [trialsQuery.data]);
useEffect(() => { useEffect(() => {
if (experimentQuery.isLoading || trialsQuery.isLoading) { if (experimentQuery.isLoading || trialsQuery.isLoading) {
setLoading(true); setLoading(true);
} else { } else {
setLoading(false); setLoading(false);
} }
}, [experimentQuery.isLoading, trialsQuery.isLoading]); }, [experimentQuery.isLoading, trialsQuery.isLoading]);
// Set breadcrumbs // Set breadcrumbs
useBreadcrumbsEffect([ useBreadcrumbsEffect([
{ {
label: "Dashboard", label: "Dashboard",
href: "/", href: "/",
}, },
{ {
label: "Studies", label: "Studies",
href: "/studies", href: "/studies",
}, },
{ {
label: experiment?.study?.name ?? "Study", label: experiment?.study?.name ?? "Study",
href: `/studies/${experiment?.study?.id}`, href: `/studies/${experiment?.study?.id}`,
}, },
{ {
label: "Experiments", label: "Experiments",
href: `/studies/${experiment?.study?.id}/experiments`, href: `/studies/${experiment?.study?.id}/experiments`,
}, },
{ {
label: experiment?.name ?? "Experiment", label: experiment?.name ?? "Experiment",
}, },
]); ]);
if (loading) return <div>Loading...</div>; if (loading) return <div>Loading...</div>;
if (experimentQuery.error) return notFound(); if (experimentQuery.error) return notFound();
if (!experiment) return notFound(); if (!experiment) return notFound();
const displayName = experiment.name ?? "Untitled Experiment"; const displayName = experiment.name ?? "Untitled Experiment";
const description = experiment.description; const description = experiment.description;
// Check if user can edit this experiment // Check if user can edit this experiment
const userRoles = session?.user?.roles?.map((r) => r.role) ?? []; const userRoles = session?.user?.roles?.map((r) => r.role) ?? [];
const canEdit = const canEdit =
userRoles.includes("administrator") || userRoles.includes("researcher"); userRoles.includes("administrator") || userRoles.includes("researcher");
const statusInfo = const statusInfo =
statusConfig[experiment.status as keyof typeof statusConfig]; statusConfig[experiment.status as keyof typeof statusConfig];
const studyId = experiment.study.id; const studyId = experiment.study.id;
const experimentId = experiment.id; const experimentId = experiment.id;
return ( return (
<EntityView> <EntityView>
<EntityViewHeader <PageHeader
title={displayName} title={displayName}
subtitle={description ?? undefined} description={description ?? undefined}
icon="TestTube" icon={TestTube}
status={{ badges={[
label: statusInfo?.label ?? "Unknown", {
variant: statusInfo?.variant ?? "secondary", label: statusInfo?.label ?? "Unknown",
icon: statusInfo?.icon ?? "TestTube", variant: statusInfo?.variant ?? "secondary",
}} },
actions={ ]}
canEdit ? ( actions={
<> canEdit ? (
<Button asChild variant="outline"> <div className="flex items-center gap-2">
<Link href={`/studies/${studyId}/experiments/${experimentId}/edit`}> <Button asChild variant="outline">
<Edit className="mr-2 h-4 w-4" /> <Link
Edit href={`/studies/${studyId}/experiments/${experimentId}/designer`}
</Link> >
</Button> <Settings className="mr-2 h-4 w-4" />
<Button asChild variant="outline"> Designer
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}> </Link>
<Settings className="mr-2 h-4 w-4" /> </Button>
Designer <Button asChild>
</Link> <Link
</Button> href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
<Button asChild> >
<Link <Play className="mr-2 h-4 w-4" />
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`} Start Trial
> </Link>
<Play className="mr-2 h-4 w-4" /> </Button>
Start Trial
</Link>
</Button>
</>
) : undefined
}
/>
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
{/* Basic Information */}
<EntityViewSection title="Information" icon="Info">
<InfoGrid
columns={2}
items={[
{
label: "Study",
value: experiment.study ? (
<Link
href={`/studies/${experiment.study.id}`}
className="text-primary hover:underline"
>
{experiment.study.name}
</Link>
) : (
"No study assigned"
),
},
{
label: "Status",
value: statusInfo?.label ?? "Unknown",
},
{
label: "Created",
value: formatDistanceToNow(experiment.createdAt, {
addSuffix: true,
}),
},
{
label: "Last Updated",
value: formatDistanceToNow(experiment.updatedAt, {
addSuffix: true,
}),
},
]}
/>
</EntityViewSection>
{/* Protocol Section */}
<EntityViewSection
title="Experiment Protocol"
icon="FileText"
actions={
canEdit && (
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
<Edit className="mr-2 h-4 w-4" />
Edit Protocol
</Link>
</Button>
)
}
>
{experiment.protocol &&
typeof experiment.protocol === "object" &&
experiment.protocol !== null ? (
<div className="space-y-3">
<div className="text-muted-foreground text-sm">
Protocol contains{" "}
{Array.isArray(
(experiment.protocol as { blocks: unknown[] }).blocks,
)
? (experiment.protocol as { blocks: unknown[] }).blocks
.length
: 0}{" "}
blocks
</div>
</div>
) : (
<EmptyState
icon="FileText"
title="No protocol defined"
description="Create an experiment protocol using the visual designer"
action={
canEdit && (
<Button asChild>
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
Open Designer
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
{/* Recent Trials */}
<EntityViewSection
title="Recent Trials"
icon="Play"
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${experiment.study?.id}/trials`}>
View All
</Link>
</Button>
}
>
{trials.length > 0 ? (
<div className="space-y-3">
{trials.slice(0, 5).map((trial) => (
<div
key={trial.id}
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
>
<div className="mb-2 flex items-center justify-between">
<Link
href={`/studies/${experiment.study.id}/trials/${trial.id}`}
className="font-medium hover:underline"
>
Trial #{trial.id.slice(-6)}
</Link>
<Badge
variant={
trial.status === "completed"
? "default"
: trial.status === "in_progress"
? "secondary"
: trial.status === "failed"
? "destructive"
: "outline"
}
>
{trial.status.charAt(0).toUpperCase() +
trial.status.slice(1).replace("_", " ")}
</Badge>
</div>
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDistanceToNow(trial.createdAt, {
addSuffix: true,
})}
</span>
{trial.duration && (
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{Math.round(trial.duration / 60)} min
</span>
)}
{trial.participant && (
<span className="flex items-center gap-1">
<Users className="h-4 w-4" />
{trial.participant.name ??
trial.participant.participantCode}
</span>
)}
</div>
</div>
))}
</div>
) : (
<EmptyState
icon="Play"
title="No trials yet"
description="Start your first trial to collect data"
action={
canEdit && (
<Button asChild>
<Link
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
>
Start Trial
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
</div>
<div className="space-y-6">
{/* Statistics */}
<EntityViewSection title="Statistics" icon="BarChart">
<StatsGrid
stats={[
{
label: "Total Trials",
value: trials.length,
},
{
label: "Completed",
value: trials.filter((t) => t.status === "completed").length,
},
{
label: "In Progress",
value: trials.filter((t) => t.status === "in_progress")
.length,
},
]}
/>
</EntityViewSection>
{/* Robot Information */}
{experiment.robot && (
<EntityViewSection title="Robot Platform" icon="Bot">
<InfoGrid
columns={1}
items={[
{
label: "Platform",
value: experiment.robot.name,
},
{
label: "Type",
value: experiment.robot.description ?? "Not specified",
},
]}
/>
</EntityViewSection>
)}
{/* Quick Actions */}
<EntityViewSection title="Quick Actions" icon="Zap">
<QuickActions
actions={[
{
label: "Export Data",
icon: "Download" as const,
href: `/studies/${studyId}/experiments/${experimentId}/export`,
},
...(canEdit
? [
{
label: "Edit Experiment",
icon: "Edit" as const,
href: `/studies/${studyId}/experiments/${experimentId}/edit`,
},
{
label: "Open Designer",
icon: "Palette" as const,
href: `/studies/${studyId}/experiments/${experimentId}/designer`,
},
]
: []),
]}
/>
</EntityViewSection>
</div>
</div> </div>
</EntityView> ) : undefined
); }
/>
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
{/* Basic Information */}
<EntityViewSection title="Information" icon="Info">
<InfoGrid
columns={2}
items={[
{
label: "Study",
value: experiment.study ? (
<Link
href={`/studies/${experiment.study.id}`}
className="text-primary hover:underline"
>
{experiment.study.name}
</Link>
) : (
"No study assigned"
),
},
{
label: "Status",
value: statusInfo?.label ?? "Unknown",
},
{
label: "Created",
value: formatDistanceToNow(experiment.createdAt, {
addSuffix: true,
}),
},
{
label: "Last Updated",
value: formatDistanceToNow(experiment.updatedAt, {
addSuffix: true,
}),
},
]}
/>
</EntityViewSection>
{/* Protocol Section */}
<EntityViewSection
title="Experiment Protocol"
icon="FileText"
actions={
canEdit && (
<Button asChild variant="outline" size="sm">
<Link
href={`/studies/${studyId}/experiments/${experimentId}/designer`}
>
<Edit className="mr-2 h-4 w-4" />
Edit Protocol
</Link>
</Button>
)
}
>
{experiment.protocol &&
typeof experiment.protocol === "object" &&
experiment.protocol !== null ? (
<div className="space-y-3">
<div className="text-muted-foreground text-sm">
Protocol contains{" "}
{Array.isArray(
(experiment.protocol as { blocks: unknown[] }).blocks,
)
? (experiment.protocol as { blocks: unknown[] }).blocks
.length
: 0}{" "}
blocks
</div>
</div>
) : (
<EmptyState
icon="FileText"
title="No protocol defined"
description="Create an experiment protocol using the visual designer"
action={
canEdit && (
<Button asChild>
<Link
href={`/studies/${studyId}/experiments/${experimentId}/designer`}
>
Open Designer
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
{/* Recent Trials */}
<EntityViewSection
title="Recent Trials"
icon="Play"
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${experiment.study?.id}/trials`}>
View All
</Link>
</Button>
}
>
{trials.length > 0 ? (
<div className="space-y-3">
{trials.slice(0, 5).map((trial) => (
<div
key={trial.id}
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
>
<div className="mb-2 flex items-center justify-between">
<Link
href={`/studies/${experiment.study.id}/trials/${trial.id}`}
className="font-medium hover:underline"
>
Trial #{trial.id.slice(-6)}
</Link>
<Badge
variant={
trial.status === "completed"
? "default"
: trial.status === "in_progress"
? "secondary"
: trial.status === "failed"
? "destructive"
: "outline"
}
>
{trial.status.charAt(0).toUpperCase() +
trial.status.slice(1).replace("_", " ")}
</Badge>
</div>
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDistanceToNow(trial.createdAt, {
addSuffix: true,
})}
</span>
{trial.duration && (
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{Math.round(trial.duration / 60)} min
</span>
)}
{trial.participant && (
<span className="flex items-center gap-1">
<Users className="h-4 w-4" />
{trial.participant.name ??
trial.participant.participantCode}
</span>
)}
</div>
</div>
))}
</div>
) : (
<EmptyState
icon="Play"
title="No trials yet"
description="Start your first trial to collect data"
action={
canEdit && (
<Button asChild>
<Link
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
>
Start Trial
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
</div>
<div className="space-y-6">
{/* Statistics */}
<EntityViewSection title="Statistics" icon="BarChart">
<StatsGrid
stats={[
{
label: "Total Trials",
value: trials.length,
},
{
label: "Completed",
value: trials.filter((t) => t.status === "completed").length,
},
{
label: "In Progress",
value: trials.filter((t) => t.status === "in_progress")
.length,
},
]}
/>
</EntityViewSection>
{/* Robot Information */}
{experiment.robot && (
<EntityViewSection title="Robot Platform" icon="Bot">
<InfoGrid
columns={1}
items={[
{
label: "Platform",
value: experiment.robot.name,
},
{
label: "Type",
value: experiment.robot.description ?? "Not specified",
},
]}
/>
</EntityViewSection>
)}
{/* Quick Actions */}
<EntityViewSection title="Quick Actions" icon="Zap">
<QuickActions
actions={[
{
label: "Export Data",
icon: "Download" as const,
},
...(canEdit
? [
{
label: "Open Designer",
icon: "Palette" as const,
href: `/studies/${studyId}/experiments/${experimentId}/designer`,
},
]
: []),
]}
/>
</EntityViewSection>
</div>
</div>
</EntityView>
);
} }

View File

@@ -0,0 +1,317 @@
"use client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { notFound } from "next/navigation";
import { FileText, Loader2, Plus, Download, Edit2, Eye, Save } from "lucide-react";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
EmptyState,
} from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { PageHeader } from "~/components/ui/page-header";
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { Markdown } from 'tiptap-markdown';
import { Table } from '@tiptap/extension-table';
import { TableRow } from '@tiptap/extension-table-row';
import { TableCell } from '@tiptap/extension-table-cell';
import { TableHeader } from '@tiptap/extension-table-header';
import { Bold, Italic, List, ListOrdered, Heading1, Heading2, Quote, Table as TableIcon } from "lucide-react";
import { downloadPdfFromHtml } from "~/lib/pdf-generator";
const Toolbar = ({ editor }: { editor: any }) => {
if (!editor) {
return null;
}
return (
<div className="border border-input bg-transparent rounded-tr-md rounded-tl-md p-1 flex items-center gap-1 flex-wrap">
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
className={editor.isActive('bold') ? 'bg-muted' : ''}
>
<Bold className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
className={editor.isActive('italic') ? 'bg-muted' : ''}
>
<Italic className="h-4 w-4" />
</Button>
<div className="w-[1px] h-6 bg-border mx-1" />
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive('heading', { level: 1 }) ? 'bg-muted' : ''}
>
<Heading1 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive('heading', { level: 2 }) ? 'bg-muted' : ''}
>
<Heading2 className="h-4 w-4" />
</Button>
<div className="w-[1px] h-6 bg-border mx-1" />
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive('bulletList') ? 'bg-muted' : ''}
>
<List className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive('orderedList') ? 'bg-muted' : ''}
>
<ListOrdered className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editor.isActive('blockquote') ? 'bg-muted' : ''}
>
<Quote className="h-4 w-4" />
</Button>
<div className="w-[1px] h-6 bg-border mx-1" />
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
>
<TableIcon className="h-4 w-4" />
</Button>
</div>
);
};
interface StudyFormsPageProps {
params: Promise<{
id: string;
}>;
}
export default function StudyFormsPage({ params }: StudyFormsPageProps) {
const { data: session } = useSession();
const utils = api.useUtils();
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(null);
const [editorTarget, setEditorTarget] = useState<string>("");
useEffect(() => {
const resolveParams = async () => {
const resolved = await params;
setResolvedParams(resolved);
};
void resolveParams();
}, [params]);
const { data: study } = api.studies.get.useQuery(
{ id: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
const { data: activeConsentForm, refetch: refetchConsentForm } =
api.studies.getActiveConsentForm.useQuery(
{ studyId: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
// Only sync once when form loads to avoid resetting user edits
useEffect(() => {
if (activeConsentForm && !editorTarget) {
setEditorTarget(activeConsentForm.content);
}
}, [activeConsentForm, editorTarget]);
const editor = useEditor({
extensions: [
StarterKit,
Table.configure({
resizable: true,
}),
TableRow,
TableHeader,
TableCell,
Markdown.configure({
transformPastedText: true,
}),
],
content: editorTarget || '',
immediatelyRender: false,
onUpdate: ({ editor }) => {
// @ts-ignore
setEditorTarget(editor.storage.markdown.getMarkdown());
},
});
// Sync Tiptap when editorTarget is set (e.g., from DB) but make sure not to overwrite active edits
useEffect(() => {
if (editor && editorTarget && editor.isEmpty) {
editor.commands.setContent(editorTarget);
}
}, [editorTarget, editor]);
const generateConsentMutation = api.studies.generateConsentForm.useMutation({
onSuccess: (data) => {
toast.success("Default Consent Form Generated!");
setEditorTarget(data.content);
editor?.commands.setContent(data.content);
void refetchConsentForm();
void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" });
},
onError: (error) => {
toast.error("Error generating consent form", { description: error.message });
},
});
const updateConsentMutation = api.studies.updateConsentForm.useMutation({
onSuccess: () => {
toast.success("Consent Form Saved Successfully!");
void refetchConsentForm();
void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" });
},
onError: (error) => {
toast.error("Error saving consent form", { description: error.message });
},
});
const handleDownloadConsent = async () => {
if (!activeConsentForm || !study || !editor) return;
try {
toast.loading("Generating Document...", { id: "pdf-gen" });
await downloadPdfFromHtml(editor.getHTML(), {
filename: `Consent_Form_${study.name.replace(/\s+/g, "_")}_v${activeConsentForm.version}.pdf`
});
toast.success("Document Downloaded Successfully!", { id: "pdf-gen" });
} catch (error) {
toast.error("Error generating PDF", { id: "pdf-gen" });
console.error(error);
}
};
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${resolvedParams?.id}` },
{ label: "Forms" },
]);
if (!session?.user) {
return notFound();
}
if (!study) return <div>Loading...</div>;
return (
<EntityView>
<PageHeader
title="Study Forms"
description="Manage consent forms and future questionnaires for this study"
icon={FileText}
/>
<div className="grid grid-cols-1 gap-8">
<EntityViewSection
title="Consent Document"
icon="FileText"
description="Design and manage the consent form that participants must sign before participating in your trials."
actions={
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => generateConsentMutation.mutate({ studyId: study.id })}
disabled={generateConsentMutation.isPending || updateConsentMutation.isPending}
>
{generateConsentMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Generate Default Template
</Button>
{activeConsentForm && (
<Button
size="sm"
onClick={() => updateConsentMutation.mutate({ studyId: study.id, content: editorTarget })}
disabled={updateConsentMutation.isPending || editorTarget === activeConsentForm.content}
>
{updateConsentMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
)}
</div>
}
>
{activeConsentForm ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium leading-none">
{activeConsentForm.title}
</p>
<p className="text-sm text-muted-foreground">
v{activeConsentForm.version} Status: Active
</p>
</div>
<div className="flex items-center gap-3">
<Button
size="sm"
variant="ghost"
onClick={handleDownloadConsent}
>
<Download className="mr-2 h-4 w-4" />
Download PDF
</Button>
<Badge variant="outline" className="bg-green-50 text-green-700 hover:bg-green-50">Active</Badge>
</div>
</div>
<div className="w-full flex justify-center bg-muted/30 p-8 rounded-md border border-border overflow-hidden">
<div className="max-w-4xl w-full bg-white dark:bg-card shadow-xl ring-1 ring-border rounded-sm flex flex-col">
<div className="border-b border-border bg-muted/50 dark:bg-muted/10">
<Toolbar editor={editor} />
</div>
<div className="min-h-[850px] px-16 py-20 text-sm editor-container bg-white dark:bg-card">
<EditorContent editor={editor} className="prose prose-sm dark:prose-invert max-w-none h-full outline-none focus:outline-none focus-visible:outline-none" />
</div>
</div>
</div>
</div>
) : (
<EmptyState
icon="FileText"
title="No Consent Form"
description="Generate a boilerplate consent form for this study to download and collect signatures."
/>
)}
</EntityViewSection>
</div>
</EntityView>
);
}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { Plus, Settings, Shield } from "lucide-react"; import { Plus, Settings, Shield, Building } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -16,6 +16,7 @@ import {
QuickActions, QuickActions,
StatsGrid, StatsGrid,
} from "~/components/ui/entity-view"; } from "~/components/ui/entity-view";
import { PageHeader } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
@@ -70,6 +71,7 @@ type Member = {
export default function StudyDetailPage({ params }: StudyDetailPageProps) { export default function StudyDetailPage({ params }: StudyDetailPageProps) {
const { data: session } = useSession(); const { data: session } = useSession();
const utils = api.useUtils();
const [study, setStudy] = useState<Study | null>(null); const [study, setStudy] = useState<Study | null>(null);
const [members, setMembers] = useState<Member[]>([]); const [members, setMembers] = useState<Member[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -167,17 +169,18 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
return ( return (
<EntityView> <EntityView>
{/* Header */} {/* Header */}
<EntityViewHeader <PageHeader
title={study.name} title={study.name}
subtitle={study.description ?? undefined} description={study.description ?? undefined}
icon="Building" icon={Building}
status={{ badges={[
label: statusInfo?.label ?? "Unknown", {
variant: statusInfo?.variant ?? "secondary", label: statusInfo?.label ?? "Unknown",
icon: statusInfo?.icon ?? "FileText", variant: statusInfo?.variant ?? "secondary",
}} },
]}
actions={ actions={
<> <div className="flex items-center gap-2">
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link href={`/studies/${study.id}/edit`}> <Link href={`/studies/${study.id}/edit`}>
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
@@ -190,7 +193,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
New Experiment New Experiment
</Link> </Link>
</Button> </Button>
</> </div>
} }
/> />
@@ -271,10 +274,10 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
</h4> </h4>
<span <span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${experiment.status === "draft" className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${experiment.status === "draft"
? "bg-gray-100 text-gray-800" ? "bg-gray-100 text-gray-800"
: experiment.status === "ready" : experiment.status === "ready"
? "bg-green-100 text-green-800" ? "bg-green-100 text-green-800"
: "bg-blue-100 text-blue-800" : "bg-blue-100 text-blue-800"
}`} }`}
> >
{experiment.status} {experiment.status}
@@ -299,12 +302,18 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm">
<Link href={`/studies/${study.id}/experiments/${experiment.id}/designer`}> <Link
href={`/studies/${study.id}/experiments/${experiment.id}/designer`}
>
Design Design
</Link> </Link>
</Button> </Button>
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm">
<Link href={`/studies/${study.id}/experiments/${experiment.id}`}>View</Link> <Link
href={`/studies/${study.id}/experiments/${experiment.id}`}
>
View
</Link>
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -3,29 +3,29 @@ import { api } from "~/trpc/server";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
interface EditParticipantPageProps { interface EditParticipantPageProps {
params: Promise<{ params: Promise<{
id: string; id: string;
participantId: string; participantId: string;
}>; }>;
} }
export default async function EditParticipantPage({ export default async function EditParticipantPage({
params, params,
}: EditParticipantPageProps) { }: EditParticipantPageProps) {
const { id: studyId, participantId } = await params; const { id: studyId, participantId } = await params;
const participant = await api.participants.get({ id: participantId }); const participant = await api.participants.get({ id: participantId });
if (!participant || participant.studyId !== studyId) { if (!participant || participant.studyId !== studyId) {
notFound(); notFound();
} }
// Transform data to match form expectations if needed, or pass directly // Transform data to match form expectations if needed, or pass directly
return ( return (
<ParticipantForm <ParticipantForm
mode="edit" mode="edit"
studyId={studyId} studyId={studyId}
participantId={participantId} participantId={participantId}
/> />
); );
} }

View File

@@ -1,108 +1,151 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { api } from "~/trpc/server"; import { api } from "~/trpc/server";
import { import {
EntityView, EntityView,
EntityViewHeader, EntityViewHeader,
EntityViewSection, EntityViewSection,
} from "~/components/ui/entity-view"; } from "~/components/ui/entity-view";
import { ParticipantDocuments } from "./participant-documents"; import { ParticipantDocuments } from "./participant-documents";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card"; import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "~/components/ui/card";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Edit } from "lucide-react"; import { Edit, Users } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { PageHeader } from "~/components/ui/page-header";
import { ParticipantConsentManager } from "~/components/participants/ParticipantConsentManager";
interface ParticipantDetailPageProps { interface ParticipantDetailPageProps {
params: Promise<{ id: string; participantId: string }>; params: Promise<{ id: string; participantId: string }>;
} }
export default async function ParticipantDetailPage({ export default async function ParticipantDetailPage({
params, params,
}: ParticipantDetailPageProps) { }: ParticipantDetailPageProps) {
const { id: studyId, participantId } = await params; const { id: studyId, participantId } = await params;
const participant = await api.participants.get({ id: participantId }); const participant = await api.participants.get({ id: participantId });
if (!participant) { if (!participant) {
notFound(); notFound();
} }
// Ensure participant belongs to study // Ensure participant belongs to study
if (participant.studyId !== studyId) { if (participant.studyId !== studyId) {
notFound(); notFound();
} }
return ( return (
<EntityView> <EntityView>
<EntityViewHeader <PageHeader
title={participant.participantCode} title={participant.participantCode}
subtitle={participant.name ?? "Unnamed Participant"} description={participant.name ?? "Unnamed Participant"}
icon="Users" icon={Users}
status={{ badges={[
label: participant.consentGiven ? "Consent Given" : "No Consent", {
variant: participant.consentGiven ? "default" : "secondary" label: participant.consentGiven ? "Consent Given" : "No Consent",
}} variant: participant.consentGiven ? "default" : "secondary",
actions={ },
<Button asChild variant="outline" size="sm"> ]}
<Link href={`/studies/${studyId}/participants/${participantId}/edit`}> actions={
<Edit className="mr-2 h-4 w-4" /> <Button asChild variant="outline" size="sm">
Edit Participant <Link
</Link> href={`/studies/${studyId}/participants/${participantId}/edit`}
</Button> >
} <Edit className="mr-2 h-4 w-4" />
Edit Participant
</Link>
</Button>
}
/>
<Tabs defaultValue="overview" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="files">Files & Documents</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="grid grid-cols-1 gap-6">
<ParticipantConsentManager
studyId={studyId}
participantId={participantId}
participantName={participant.name}
participantCode={participant.participantCode}
consentGiven={participant.consentGiven}
consentDate={participant.consentDate}
existingConsent={participant.consents[0] ?? null}
/> />
<EntityViewSection title="Participant Information" icon="Info">
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
<div>
<span className="text-muted-foreground mb-1 block">Code</span>
<span className="text-base font-medium">
{participant.participantCode}
</span>
</div>
<Tabs defaultValue="overview" className="w-full"> <div>
<TabsList className="mb-4"> <span className="text-muted-foreground mb-1 block">Name</span>
<TabsTrigger value="overview">Overview</TabsTrigger> <span className="text-base font-medium">
<TabsTrigger value="files">Files & Documents</TabsTrigger> {participant.name || "-"}
</TabsList> </span>
</div>
<TabsContent value="overview"> <div>
<div className="grid gap-6 grid-cols-1"> <span className="text-muted-foreground mb-1 block">
<EntityViewSection title="Participant Information" icon="Info"> Email
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4"> </span>
<div> <span className="text-base font-medium">
<span className="text-muted-foreground block mb-1">Code</span> {participant.email || "-"}
<span className="font-medium text-base">{participant.participantCode}</span> </span>
</div> </div>
<div> <div>
<span className="text-muted-foreground block mb-1">Name</span> <span className="text-muted-foreground mb-1 block">
<span className="font-medium text-base">{participant.name || "-"}</span> Added
</div> </span>
<span className="text-base font-medium">
{new Date(participant.createdAt).toLocaleDateString()}
</span>
</div>
<div> <div>
<span className="text-muted-foreground block mb-1">Email</span> <span className="text-muted-foreground mb-1 block">Age</span>
<span className="font-medium text-base">{participant.email || "-"}</span> <span className="text-base font-medium">
</div> {(participant.demographics as any)?.age || "-"}
</span>
</div>
<div> <div>
<span className="text-muted-foreground block mb-1">Added</span> <span className="text-muted-foreground mb-1 block">
<span className="font-medium text-base">{new Date(participant.createdAt).toLocaleDateString()}</span> Gender
</div> </span>
<span className="text-base font-medium capitalize">
{(participant.demographics as any)?.gender?.replace(
"_",
" ",
) || "-"}
</span>
</div>
</div>
</EntityViewSection>
</div>
</TabsContent>
<div> <TabsContent value="files">
<span className="text-muted-foreground block mb-1">Age</span> <EntityViewSection title="Documents" icon="FileText">
<span className="font-medium text-base">{(participant.demographics as any)?.age || "-"}</span> <ParticipantDocuments participantId={participantId} />
</div> </EntityViewSection>
</TabsContent>
<div> </Tabs>
<span className="text-muted-foreground block mb-1">Gender</span> </EntityView>
<span className="font-medium capitalize text-base">{(participant.demographics as any)?.gender?.replace("_", " ") || "-"}</span> );
</div>
</div>
</EntityViewSection>
</div>
</TabsContent>
<TabsContent value="files">
<EntityViewSection title="Documents" icon="FileText">
<ParticipantDocuments participantId={participantId} />
</EntityViewSection>
</TabsContent>
</Tabs>
</EntityView>
);
} }

View File

@@ -4,184 +4,192 @@ import { useState } from "react";
import { Upload, FileText, Trash2, Download, Loader2 } from "lucide-react"; import { Upload, FileText, Trash2, Download, Loader2 } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "~/components/ui/card"; } from "~/components/ui/card";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { formatBytes } from "~/lib/utils"; import { formatBytes } from "~/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
interface ParticipantDocumentsProps { interface ParticipantDocumentsProps {
participantId: string; participantId: string;
} }
export function ParticipantDocuments({ participantId }: ParticipantDocumentsProps) { export function ParticipantDocuments({
const [isUploading, setIsUploading] = useState(false); participantId,
const utils = api.useUtils(); }: ParticipantDocumentsProps) {
const [isUploading, setIsUploading] = useState(false);
const utils = api.useUtils();
const { data: documents, isLoading } = api.files.listParticipantDocuments.useQuery({ const { data: documents, isLoading } =
api.files.listParticipantDocuments.useQuery({
participantId,
});
const getPresignedUrl = api.files.getPresignedUrl.useMutation();
const registerUpload = api.files.registerUpload.useMutation();
const deleteDocument = api.files.deleteDocument.useMutation({
onSuccess: () => {
toast.success("Document deleted");
utils.files.listParticipantDocuments.invalidate({ participantId });
},
onError: (err) => toast.error(`Failed to delete: ${err.message}`),
});
// Since presigned URLs are for PUT, we can use a direct fetch
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
try {
// 1. Get presigned URL
const { url, storagePath } = await getPresignedUrl.mutateAsync({
filename: file.name,
contentType: file.type || "application/octet-stream",
participantId, participantId,
}); });
const getPresignedUrl = api.files.getPresignedUrl.useMutation(); // 2. Upload to MinIO/S3
const registerUpload = api.files.registerUpload.useMutation(); const uploadRes = await fetch(url, {
const deleteDocument = api.files.deleteDocument.useMutation({ method: "PUT",
onSuccess: () => { body: file,
toast.success("Document deleted"); headers: {
utils.files.listParticipantDocuments.invalidate({ participantId }); "Content-Type": file.type || "application/octet-stream",
}, },
onError: (err) => toast.error(`Failed to delete: ${err.message}`), });
});
// Since presigned URLs are for PUT, we can use a direct fetch if (!uploadRes.ok) {
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { throw new Error("Upload to storage failed");
const file = e.target.files?.[0]; }
if (!file) return;
setIsUploading(true); // 3. Register in DB
try { await registerUpload.mutateAsync({
// 1. Get presigned URL participantId,
const { url, storagePath } = await getPresignedUrl.mutateAsync({ name: file.name,
filename: file.name, type: file.type,
contentType: file.type || "application/octet-stream", storagePath,
participantId, fileSize: file.size,
}); });
// 2. Upload to MinIO/S3 toast.success("File uploaded successfully");
const uploadRes = await fetch(url, { utils.files.listParticipantDocuments.invalidate({ participantId });
method: "PUT", } catch (error) {
body: file, console.error(error);
headers: { toast.error("Failed to upload file");
"Content-Type": file.type || "application/octet-stream", } finally {
}, setIsUploading(false);
}); // Reset input
e.target.value = "";
}
};
if (!uploadRes.ok) { const handleDownload = async (storagePath: string, filename: string) => {
throw new Error("Upload to storage failed"); // We would typically get a temporary download URL here
} // For now assuming public bucket or implementing a separate download procedure
// Let's implement a quick procedure call right here via client or assume the server router has it.
// I added getDownloadUrl to the router in previous steps.
try {
const { url } = await utils.client.files.getDownloadUrl.query({
storagePath,
});
window.open(url, "_blank");
} catch (e) {
toast.error("Could not get download URL");
}
};
// 3. Register in DB return (
await registerUpload.mutateAsync({ <Card>
participantId, <CardHeader>
name: file.name, <div className="flex items-center justify-between">
type: file.type, <div className="space-y-1">
storagePath, <CardTitle>Documents</CardTitle>
fileSize: file.size, <CardDescription>
}); Manage consent forms and other files for this participant.
</CardDescription>
toast.success("File uploaded successfully"); </div>
utils.files.listParticipantDocuments.invalidate({ participantId }); <div className="flex items-center gap-2">
} catch (error) { <Button disabled={isUploading} asChild>
console.error(error); <label className="cursor-pointer">
toast.error("Failed to upload file"); {isUploading ? (
} finally { <Loader2 className="mr-2 h-4 w-4 animate-spin" />
setIsUploading(false);
// Reset input
e.target.value = "";
}
};
const handleDownload = async (storagePath: string, filename: string) => {
// We would typically get a temporary download URL here
// For now assuming public bucket or implementing a separate download procedure
// Let's implement a quick procedure call right here via client or assume the server router has it.
// I added getDownloadUrl to the router in previous steps.
try {
const { url } = await utils.client.files.getDownloadUrl.query({ storagePath });
window.open(url, "_blank");
} catch (e) {
toast.error("Could not get download URL");
}
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="space-y-1">
<CardTitle>Documents</CardTitle>
<CardDescription>
Manage consent forms and other files for this participant.
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button disabled={isUploading} asChild>
<label className="cursor-pointer">
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Upload PDF
<input
type="file"
className="hidden"
accept=".pdf,.doc,.docx,.txt" // User asked for PDF, but generic is fine
onChange={handleFileUpload}
disabled={isUploading}
/>
</label>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : documents?.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<FileText className="mb-2 h-8 w-8 opacity-50" />
<p>No documents uploaded yet.</p>
</div>
) : ( ) : (
<div className="space-y-2"> <Upload className="mr-2 h-4 w-4" />
{documents?.map((doc) => (
<div
key={doc.id}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-muted/50"
>
<div className="flex items-center gap-3">
<div className="rounded-md bg-blue-50 p-2">
<FileText className="h-4 w-4 text-blue-600" />
</div>
<div>
<p className="font-medium">{doc.name}</p>
<p className="text-xs text-muted-foreground">
{formatBytes(doc.fileSize ?? 0)} {new Date(doc.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleDownload(doc.storagePath, doc.name)}
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => {
if (confirm("Are you sure you want to delete this file?")) {
deleteDocument.mutate({ id: doc.id });
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)} )}
</CardContent> Upload PDF
</Card> <input
); type="file"
className="hidden"
accept=".pdf,.doc,.docx,.txt" // User asked for PDF, but generic is fine
onChange={handleFileUpload}
disabled={isUploading}
/>
</label>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center p-4">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : documents?.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-center">
<FileText className="mb-2 h-8 w-8 opacity-50" />
<p>No documents uploaded yet.</p>
</div>
) : (
<div className="space-y-2">
{documents?.map((doc) => (
<div
key={doc.id}
className="hover:bg-muted/50 flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<div className="rounded-md bg-blue-50 p-2">
<FileText className="h-4 w-4 text-blue-600" />
</div>
<div>
<p className="font-medium">{doc.name}</p>
<p className="text-muted-foreground text-xs">
{formatBytes(doc.fileSize ?? 0)} {" "}
{new Date(doc.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleDownload(doc.storagePath, doc.name)}
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => {
if (
confirm("Are you sure you want to delete this file?")
) {
deleteDocument.mutate({ id: doc.id });
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
} }

View File

@@ -13,120 +13,112 @@ import { TrialAnalysisView } from "~/components/trials/views/TrialAnalysisView";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
function AnalysisPageContent() { function AnalysisPageContent() {
const params = useParams(); const params = useParams();
const studyId: string = typeof params.id === "string" ? params.id : ""; const studyId: string = typeof params.id === "string" ? params.id : "";
const trialId: string = const trialId: string =
typeof params.trialId === "string" ? params.trialId : ""; typeof params.trialId === "string" ? params.trialId : "";
const { setSelectedStudyId, selectedStudyId } = useStudyContext(); const { setSelectedStudyId, selectedStudyId } = useStudyContext();
const { study } = useSelectedStudyDetails(); const { study } = useSelectedStudyDetails();
// Get trial data // Get trial data
const { const {
data: trial, data: trial,
isLoading, isLoading,
error, error,
} = api.trials.get.useQuery({ id: trialId }, { enabled: !!trialId }); } = api.trials.get.useQuery({ id: trialId }, { enabled: !!trialId });
// Set breadcrumbs // Set breadcrumbs
useBreadcrumbsEffect([ useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" }, { label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" }, { label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${studyId}` }, { label: study?.name ?? "Study", href: `/studies/${studyId}` },
{ label: "Trials", href: `/studies/${studyId}/trials` }, { label: "Trials", href: `/studies/${studyId}/trials` },
{ {
label: trial?.experiment.name ?? "Trial", label: trial?.experiment.name ?? "Trial",
href: `/studies/${studyId}/trials`, href: `/studies/${studyId}/trials`,
}, },
{ label: "Analysis" }, { label: "Analysis" },
]); ]);
// Sync selected study (unified study-context) // Sync selected study (unified study-context)
useEffect(() => { useEffect(() => {
if (studyId && selectedStudyId !== studyId) { if (studyId && selectedStudyId !== studyId) {
setSelectedStudyId(studyId); setSelectedStudyId(studyId);
}
}, [studyId, selectedStudyId, setSelectedStudyId]);
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-muted-foreground">Loading analysis...</div>
</div>
);
} }
}, [studyId, selectedStudyId, setSelectedStudyId]);
if (error || !trial) { if (isLoading) {
return (
<div className="space-y-6">
<PageHeader
title="Trial Analysis"
description="Analyze trial results"
icon={LineChart}
actions={
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/trials`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trials
</Link>
</Button>
}
/>
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<h3 className="text-destructive mb-2 text-lg font-semibold">
{error ? "Error Loading Trial" : "Trial Not Found"}
</h3>
<p className="text-muted-foreground">
{error?.message || "The requested trial could not be found."}
</p>
</div>
</div>
</div>
);
}
const trialData = {
...trial,
startedAt: trial.startedAt ? new Date(trial.startedAt) : null,
completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
eventCount: (trial as any).eventCount,
mediaCount: (trial as any).mediaCount,
};
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-96 items-center justify-center">
<PageHeader <div className="text-muted-foreground">Loading analysis...</div>
title="Trial Analysis" </div>
description={`Analysis for Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
icon={LineChart}
actions={
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/trials/${trialId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trial Details
</Link>
</Button>
}
/>
<div className="min-h-0 flex-1">
<TrialAnalysisView trial={trialData} />
</div>
</div>
); );
}
if (error || !trial) {
return (
<div className="space-y-6">
<PageHeader
title="Trial Analysis"
description="Analyze trial results"
icon={LineChart}
actions={
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/trials`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trials
</Link>
</Button>
}
/>
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<h3 className="text-destructive mb-2 text-lg font-semibold">
{error ? "Error Loading Trial" : "Trial Not Found"}
</h3>
<p className="text-muted-foreground">
{error?.message || "The requested trial could not be found."}
</p>
</div>
</div>
</div>
);
}
const customTrialData = {
...trial,
startedAt: trial.startedAt ? new Date(trial.startedAt) : null,
completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
eventCount: (trial as any).eventCount,
mediaCount: (trial as any).mediaCount,
media:
trial.media?.map((m) => ({
...m,
mediaType: m.mediaType ?? "video",
format: m.format ?? undefined,
contentType: m.contentType ?? undefined,
})) ?? [],
};
return (
<TrialAnalysisView
trial={customTrialData}
backHref={`/studies/${studyId}/trials/${trialId}`}
/>
);
} }
export default function TrialAnalysisPage() { export default function TrialAnalysisPage() {
return ( return (
<Suspense <Suspense
fallback={ fallback={
<div className="flex h-96 items-center justify-center"> <div className="flex h-96 items-center justify-center">
<div className="text-muted-foreground">Loading...</div> <div className="text-muted-foreground">Loading...</div>
</div> </div>
} }
> >
<AnalysisPageContent /> <AnalysisPageContent />
</Suspense> </Suspense>
); );
} }

View File

@@ -3,7 +3,14 @@
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Suspense, useEffect } from "react"; import { Suspense, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { Play, Zap, ArrowLeft, User, FlaskConical, LineChart } from "lucide-react"; import {
Play,
Zap,
ArrowLeft,
User,
FlaskConical,
LineChart,
} from "lucide-react";
import { PageHeader } from "~/components/ui/page-header"; import { PageHeader } from "~/components/ui/page-header";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
@@ -140,6 +147,12 @@ function TrialDetailContent() {
title={`Trial: ${trial.participant.participantCode}`} title={`Trial: ${trial.participant.participantCode}`}
description={`${trial.experiment.name} - Session ${trial.sessionNumber}`} description={`${trial.experiment.name} - Session ${trial.sessionNumber}`}
icon={Play} icon={Play}
badges={[
{
label: trial.status.replace("_", " ").toUpperCase(),
variant: getStatusBadgeVariant(trial.status),
},
]}
actions={ actions={
<div className="flex gap-2"> <div className="flex gap-2">
{trial.status === "scheduled" && ( {trial.status === "scheduled" && (
@@ -150,13 +163,13 @@ function TrialDetailContent() {
)} )}
{(trial.status === "in_progress" || {(trial.status === "in_progress" ||
trial.status === "scheduled") && ( trial.status === "scheduled") && (
<Button asChild> <Button asChild>
<Link href={`/studies/${studyId}/trials/${trialId}/wizard`}> <Link href={`/studies/${studyId}/trials/${trialId}/wizard`}>
<Zap className="mr-2 h-4 w-4" /> <Zap className="mr-2 h-4 w-4" />
Wizard Interface Wizard Interface
</Link> </Link>
</Button> </Button>
)} )}
{trial.status === "completed" && ( {trial.status === "completed" && (
<Button asChild> <Button asChild>
<Link href={`/studies/${studyId}/trials/${trialId}/analysis`}> <Link href={`/studies/${studyId}/trials/${trialId}/analysis`}>

View File

@@ -171,10 +171,27 @@ function WizardPageContent() {
const renderView = () => { const renderView = () => {
const trialData = { const trialData = {
...trial, id: trial.id,
status: trial.status,
scheduledAt: trial.scheduledAt,
startedAt: trial.startedAt,
completedAt: trial.completedAt,
duration: trial.duration,
sessionNumber: trial.sessionNumber,
notes: trial.notes,
metadata: trial.metadata as Record<string, unknown> | null, metadata: trial.metadata as Record<string, unknown> | null,
experimentId: trial.experimentId,
participantId: trial.participantId,
wizardId: trial.wizardId,
experiment: {
id: trial.experiment.id,
name: trial.experiment.name,
description: trial.experiment.description,
studyId: trial.experiment.studyId,
},
participant: { participant: {
...trial.participant, id: trial.participant.id,
participantCode: trial.participant.participantCode,
demographics: trial.participant.demographics as Record< demographics: trial.participant.demographics as Record<
string, string,
unknown unknown
@@ -184,7 +201,7 @@ function WizardPageContent() {
switch (currentRole) { switch (currentRole) {
case "wizard": case "wizard":
return <WizardView trial={trialData} />; return <WizardView trial={trialData} userRole={currentRole} />;
case "observer": case "observer":
return <ObserverView trial={trialData} />; return <ObserverView trial={trialData} />;
case "participant": case "participant":
@@ -194,27 +211,7 @@ function WizardPageContent() {
} }
}; };
return ( return <div>{renderView()}</div>;
<div className="flex h-full flex-col">
<PageHeader
title={getViewTitle(currentRole)}
description={`Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
icon={getViewIcon(currentRole)}
actions={
currentRole !== "participant" ? (
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/trials/${trialId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trial
</Link>
</Button>
) : null
}
/>
<div className="min-h-0 flex-1">{renderView()}</div>
</div>
);
} }
export default function TrialWizardPage() { export default function TrialWizardPage() {

View File

@@ -25,7 +25,7 @@ const handler = (req: NextRequest) =>
env.NODE_ENV === "development" env.NODE_ENV === "development"
? ({ path, error }) => { ? ({ path, error }) => {
console.error( console.error(
` tRPC failed on ${path ?? "<no-path>"}: ${error.message}`, `[tRPC Error] tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
); );
} }
: undefined, : undefined,

View File

@@ -1,4 +1,4 @@
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { import {
@@ -9,7 +9,7 @@ import {
} from "~/lib/storage/minio"; } from "~/lib/storage/minio";
import { auth } from "~/server/auth"; import { auth } from "~/server/auth";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { mediaCaptures, trials } from "~/server/db/schema"; import { experiments, mediaCaptures, studyMembers, trials } from "~/server/db/schema";
const uploadSchema = z.object({ const uploadSchema = z.object({
trialId: z.string().optional(), trialId: z.string().optional(),
@@ -71,16 +71,37 @@ export async function POST(request: NextRequest) {
// Check trial access if trialId is provided // Check trial access if trialId is provided
if (validatedTrialId) { if (validatedTrialId) {
const trial = await db const trial = await db
.select() .select({
id: trials.id,
studyId: experiments.studyId,
})
.from(trials) .from(trials)
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
.where(eq(trials.id, validatedTrialId)) .where(eq(trials.id, validatedTrialId))
.limit(1); .limit(1);
if (!trial.length) { if (!trial.length || !trial[0]) {
return NextResponse.json({ error: "Trial not found" }, { status: 404 }); return NextResponse.json({ error: "Trial not found" }, { status: 404 });
} }
// TODO: Check if user has access to this trial through study membership // Check if user has access to this trial through study membership
const membership = await db
.select()
.from(studyMembers)
.where(
and(
eq(studyMembers.studyId, trial[0].studyId),
eq(studyMembers.userId, session.user.id)
)
)
.limit(1);
if (!membership.length) {
return NextResponse.json(
{ error: "Insufficient permissions to upload to this trial" },
{ status: 403 }
);
}
} }
// Generate unique file key // Generate unique file key

View File

@@ -10,8 +10,9 @@ import {
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle CardTitle,
} from "~/components/ui/card"; } from "~/components/ui/card";
import { Checkbox } from "~/components/ui/checkbox";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { Logo } from "~/components/ui/logo"; import { Logo } from "~/components/ui/logo";
@@ -21,6 +22,7 @@ export default function SignInPage() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [notRobot, setNotRobot] = useState(false);
const router = useRouter(); const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@@ -28,6 +30,12 @@ export default function SignInPage() {
setIsLoading(true); setIsLoading(true);
setError(""); setError("");
if (!notRobot) {
setError("Please confirm you're not a robot");
setIsLoading(false);
return;
}
try { try {
const result = await signIn("credentials", { const result = await signIn("credentials", {
email, email,
@@ -53,25 +61,30 @@ export default function SignInPage() {
}; };
return ( return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4"> <div className="bg-background relative flex min-h-screen items-center justify-center overflow-hidden px-4">
{/* Background Gradients */} {/* Background Gradients */}
<div className="absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full bg-primary/20 blur-3xl opacity-30 dark:opacity-20" /> <div className="bg-primary/20 absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full opacity-30 blur-3xl dark:opacity-20" />
<div className="absolute bottom-0 right-0 -z-10 h-[300px] w-[300px] rounded-full bg-violet-500/10 blur-3xl" /> <div className="absolute right-0 bottom-0 -z-10 h-[300px] w-[300px] rounded-full bg-violet-500/10 blur-3xl" />
<div className="w-full max-w-md animate-in fade-in zoom-in-95 duration-500"> <div className="animate-in fade-in zoom-in-95 w-full max-w-md duration-500">
{/* Header */} {/* Header */}
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<Link href="/" className="inline-flex items-center justify-center transition-opacity hover:opacity-80"> <Link
<Logo iconSize="lg" showText={false} /> href="/"
className="inline-flex items-center justify-center transition-opacity hover:opacity-80"
>
<Logo iconSize="lg" showText={true} />
</Link> </Link>
<h1 className="mt-6 text-2xl font-bold tracking-tight text-foreground">Welcome back</h1> <h1 className="text-foreground mt-6 text-2xl font-bold tracking-tight">
<p className="mt-2 text-sm text-muted-foreground"> Welcome back
</h1>
<p className="text-muted-foreground mt-2 text-sm">
Sign in to your research account Sign in to your research account
</p> </p>
</div> </div>
{/* Sign In Card */} {/* Sign In Card */}
<Card className="border-muted/40 bg-card/50 backdrop-blur-sm shadow-xl"> <Card className="border-muted/40 bg-card/50 shadow-xl backdrop-blur-sm">
<CardHeader> <CardHeader>
<CardTitle>Sign In</CardTitle> <CardTitle>Sign In</CardTitle>
<CardDescription> <CardDescription>
@@ -81,7 +94,7 @@ export default function SignInPage() {
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{error && ( {error && (
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive font-medium border border-destructive/20"> <div className="bg-destructive/15 text-destructive border-destructive/20 rounded-md border p-3 text-sm font-medium">
{error} {error}
</div> </div>
)} )}
@@ -103,7 +116,12 @@ export default function SignInPage() {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">Password</Label>
<Link href="#" className="text-xs text-primary hover:underline">Forgot password?</Link> <Link
href="#"
className="text-primary text-xs hover:underline"
>
Forgot password?
</Link>
</div> </div>
<Input <Input
id="password" id="password"
@@ -116,16 +134,39 @@ export default function SignInPage() {
/> />
</div> </div>
<Button type="submit" className="w-full" disabled={isLoading} size="lg"> <div className="flex items-center space-x-2 py-2">
<Checkbox
id="not-robot"
checked={notRobot}
onCheckedChange={(checked) => setNotRobot(checked === true)}
disabled={isLoading}
/>
<label
htmlFor="not-robot"
className="cursor-pointer text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I&apos;m not a robot{" "}
<span className="text-muted-foreground text-xs italic">
(ironic, isn&apos;t it?)
</span>
</label>
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading}
size="lg"
>
{isLoading ? "Signing in..." : "Sign In"} {isLoading ? "Signing in..." : "Sign In"}
</Button> </Button>
</form> </form>
<div className="mt-6 text-center text-sm text-muted-foreground"> <div className="text-muted-foreground mt-6 text-center text-sm">
Don&apos;t have an account?{" "} Don&apos;t have an account?{" "}
<Link <Link
href="/auth/signup" href="/auth/signup"
className="font-medium text-primary hover:text-primary/80" className="text-primary hover:text-primary/80 font-medium"
> >
Sign up Sign up
</Link> </Link>
@@ -134,7 +175,7 @@ export default function SignInPage() {
</Card> </Card>
{/* Footer */} {/* Footer */}
<div className="mt-8 text-center text-xs text-muted-foreground"> <div className="text-muted-foreground mt-8 text-center text-xs">
<p> <p>
&copy; {new Date().getFullYear()} HRIStudio. All rights reserved. &copy; {new Date().getFullYear()} HRIStudio. All rights reserved.
</p> </p>

View File

@@ -6,11 +6,11 @@ import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle CardTitle,
} from "~/components/ui/card"; } from "~/components/ui/card";
export default function SignOutPage() { export default function SignOutPage() {
@@ -44,7 +44,7 @@ export default function SignOutPage() {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100"> <div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div> <div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-b-2 border-blue-600"></div>
<p className="text-slate-600">Loading...</p> <p className="text-slate-600">Loading...</p>
</div> </div>
</div> </div>
@@ -79,7 +79,8 @@ export default function SignOutPage() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-700"> <div className="rounded-md bg-blue-50 p-3 text-sm text-blue-700">
<p className="font-medium"> <p className="font-medium">
Currently signed in as: {session.user.name ?? session.user.email} Currently signed in as:{" "}
{session.user.name ?? session.user.email}
</p> </p>
</div> </div>

View File

@@ -9,7 +9,7 @@ import {
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle CardTitle,
} from "~/components/ui/card"; } from "~/components/ui/card";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
@@ -56,25 +56,30 @@ export default function SignUpPage() {
}; };
return ( return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4"> <div className="bg-background relative flex min-h-screen items-center justify-center overflow-hidden px-4">
{/* Background Gradients */} {/* Background Gradients */}
<div className="absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full bg-primary/20 blur-3xl opacity-30 dark:opacity-20" /> <div className="bg-primary/20 absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full opacity-30 blur-3xl dark:opacity-20" />
<div className="absolute bottom-0 left-0 -z-10 h-[300px] w-[300px] rounded-full bg-blue-500/10 blur-3xl" /> <div className="absolute bottom-0 left-0 -z-10 h-[300px] w-[300px] rounded-full bg-blue-500/10 blur-3xl" />
<div className="w-full max-w-md animate-in fade-in zoom-in-95 duration-500"> <div className="animate-in fade-in zoom-in-95 w-full max-w-md duration-500">
{/* Header */} {/* Header */}
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<Link href="/" className="inline-flex items-center justify-center transition-opacity hover:opacity-80"> <Link
href="/"
className="inline-flex items-center justify-center transition-opacity hover:opacity-80"
>
<Logo iconSize="lg" showText={false} /> <Logo iconSize="lg" showText={false} />
</Link> </Link>
<h1 className="mt-6 text-2xl font-bold tracking-tight text-foreground">Create an account</h1> <h1 className="text-foreground mt-6 text-2xl font-bold tracking-tight">
<p className="mt-2 text-sm text-muted-foreground"> Create an account
</h1>
<p className="text-muted-foreground mt-2 text-sm">
Start your journey in HRI research Start your journey in HRI research
</p> </p>
</div> </div>
{/* Sign Up Card */} {/* Sign Up Card */}
<Card className="border-muted/40 bg-card/50 backdrop-blur-sm shadow-xl"> <Card className="border-muted/40 bg-card/50 shadow-xl backdrop-blur-sm">
<CardHeader> <CardHeader>
<CardTitle>Sign Up</CardTitle> <CardTitle>Sign Up</CardTitle>
<CardDescription> <CardDescription>
@@ -84,7 +89,7 @@ export default function SignUpPage() {
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{error && ( {error && (
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive font-medium border border-destructive/20"> <div className="bg-destructive/15 text-destructive border-destructive/20 rounded-md border p-3 text-sm font-medium">
{error} {error}
</div> </div>
)} )}
@@ -155,15 +160,17 @@ export default function SignUpPage() {
disabled={createUser.isPending} disabled={createUser.isPending}
size="lg" size="lg"
> >
{createUser.isPending ? "Creating account..." : "Create Account"} {createUser.isPending
? "Creating account..."
: "Create Account"}
</Button> </Button>
</form> </form>
<div className="mt-6 text-center text-sm text-muted-foreground"> <div className="text-muted-foreground mt-6 text-center text-sm">
Already have an account?{" "} Already have an account?{" "}
<Link <Link
href="/auth/signin" href="/auth/signin"
className="font-medium text-primary hover:text-primary/80" className="text-primary hover:text-primary/80 font-medium"
> >
Sign in Sign in
</Link> </Link>
@@ -172,7 +179,7 @@ export default function SignUpPage() {
</Card> </Card>
{/* Footer */} {/* Footer */}
<div className="mt-8 text-center text-xs text-muted-foreground"> <div className="text-muted-foreground mt-8 text-center text-xs">
<p> <p>
&copy; {new Date().getFullYear()} HRIStudio. All rights reserved. &copy; {new Date().getFullYear()} HRIStudio. All rights reserved.
</p> </p>

View File

@@ -7,18 +7,26 @@ import { formatDistanceToNow } from "date-fns";
import { import {
Activity, Activity,
ArrowRight, ArrowRight,
Bot,
Calendar, Calendar,
CheckCircle,
CheckCircle2, CheckCircle2,
Clock, Clock,
FlaskConical,
HelpCircle, HelpCircle,
LayoutDashboard, LayoutDashboard,
MoreHorizontal, MoreHorizontal,
Play, Play,
PlayCircle, PlayCircle,
Plus, Plus,
Search,
Settings, Settings,
Users, Users,
Radio,
Gamepad2,
AlertTriangle,
Bot,
User,
MessageSquare,
} from "lucide-react"; } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@@ -49,9 +57,11 @@ import { Badge } from "~/components/ui/badge";
import { ScrollArea } from "~/components/ui/scroll-area"; import { ScrollArea } from "~/components/ui/scroll-area";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { useTour } from "~/components/onboarding/TourProvider"; import { useTour } from "~/components/onboarding/TourProvider";
import { useSession } from "next-auth/react";
export default function DashboardPage() { export default function DashboardPage() {
const { startTour } = useTour(); const { startTour } = useTour();
const { data: session } = useSession();
const [studyFilter, setStudyFilter] = React.useState<string | null>(null); const [studyFilter, setStudyFilter] = React.useState<string | null>(null);
// --- Data Fetching --- // --- Data Fetching ---
@@ -65,14 +75,13 @@ export default function DashboardPage() {
studyId: studyFilter ?? undefined, studyId: studyFilter ?? undefined,
}); });
const { data: scheduledTrials } = api.trials.list.useQuery({ const { data: liveTrials } = api.dashboard.getLiveTrials.useQuery(
studyId: studyFilter ?? undefined, { studyId: studyFilter ?? undefined },
status: "scheduled", { refetchInterval: 5000 },
limit: 5, );
});
const { data: recentActivity } = api.dashboard.getRecentActivity.useQuery({ const { data: recentActivity } = api.dashboard.getRecentActivity.useQuery({
limit: 10, limit: 15,
studyId: studyFilter ?? undefined, studyId: studyFilter ?? undefined,
}); });
@@ -81,19 +90,40 @@ export default function DashboardPage() {
studyId: studyFilter ?? undefined, studyId: studyFilter ?? undefined,
}); });
const userName = session?.user?.name ?? "Researcher";
const getWelcomeMessage = () => {
const hour = new Date().getHours();
let greeting = "Good evening";
if (hour < 12) greeting = "Good morning";
else if (hour < 18) greeting = "Good afternoon";
return `${greeting}, ${userName.split(" ")[0]}`;
};
return ( return (
<div className="flex flex-col space-y-8 animate-in fade-in duration-500"> <div className="animate-in fade-in space-y-8 duration-500">
{/* Header Section */} {/* Header Section */}
<div id="dashboard-header" className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div
id="dashboard-header"
className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"
>
<div> <div>
<h1 className="text-3xl font-bold tracking-tight text-foreground">Dashboard</h1> <h1 className="text-foreground text-3xl font-bold tracking-tight">
{getWelcomeMessage()}
</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Overview of your research activities and upcoming tasks. Here's what's happening with your research today.
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => startTour("dashboard")} title="Start Tour"> <Button
variant="ghost"
size="icon"
onClick={() => startTour("dashboard")}
title="Start Tour"
>
<HelpCircle className="h-5 w-5" /> <HelpCircle className="h-5 w-5" />
</Button> </Button>
<Select <Select
@@ -102,7 +132,7 @@ export default function DashboardPage() {
setStudyFilter(value === "all" ? null : value) setStudyFilter(value === "all" ? null : value)
} }
> >
<SelectTrigger className="w-[200px] bg-background"> <SelectTrigger className="bg-background w-[200px]">
<SelectValue placeholder="All Studies" /> <SelectValue placeholder="All Studies" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -123,166 +153,296 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{/* Stats Cards */} {/* Main Stats Grid */}
<div id="tour-dashboard-stats" className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div
<StatsCard id="tour-dashboard-stats"
title="Total Participants" className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"
value={stats?.totalParticipants ?? 0} >
icon={Users}
description="Across all studies"
trend="+2 this week"
/>
<StatsCard <StatsCard
title="Active Trials" title="Active Trials"
value={stats?.activeTrials ?? 0} value={stats?.activeTrials ?? 0}
icon={Activity} icon={Activity}
description="Currently in progress" description="Currently running sessions"
iconColor="text-emerald-500"
/> />
<StatsCard <StatsCard
title="Completed Trials" title="Completed Today"
value={stats?.completedToday ?? 0} value={stats?.completedToday ?? 0}
icon={CheckCircle2} icon={CheckCircle}
description="Completed today" description="Successful completions"
iconColor="text-blue-500"
/> />
<StatsCard <StatsCard
title="Scheduled" title="Scheduled"
value={stats?.scheduledTrials ?? 0} value={stats?.scheduledTrials ?? 0}
icon={Calendar} icon={Calendar}
description="Upcoming sessions" description="Upcoming sessions"
iconColor="text-violet-500"
/>
<StatsCard
title="Total Interventions"
value={stats?.totalInterventions ?? 0}
icon={Gamepad2}
description="Wizard manual overrides"
iconColor="text-orange-500"
/> />
</div> </div>
{/* Action Center & Recent Activity */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
{/* Quick Actions Card */}
{/* Main Column: Scheduled Trials & Study Progress */} <Card className="from-primary/5 to-background border-primary/20 col-span-3 h-fit bg-gradient-to-br">
<div className="col-span-4 space-y-4"> <CardHeader>
<CardTitle>Quick Actions</CardTitle>
{/* Scheduled Trials */} <CardDescription>Common tasks to get you started</CardDescription>
<Card id="tour-scheduled-trials" className="col-span-4 border-muted/40 shadow-sm"> </CardHeader>
<CardHeader> <CardContent className="grid gap-4">
<div className="flex items-center justify-between"> <Button
<div> variant="outline"
<CardTitle>Upcoming Sessions</CardTitle> className="border-primary/20 hover:border-primary/50 hover:bg-primary/5 group h-auto justify-start px-4 py-4"
<CardDescription> asChild
You have {scheduledTrials?.length ?? 0} scheduled trials coming up. >
</CardDescription> <Link href="/studies/new">
<div className="bg-primary/10 group-hover:bg-primary/20 mr-4 rounded-full p-2 transition-colors">
<FlaskConical className="text-primary h-5 w-5" />
</div> </div>
<Button variant="ghost" size="sm" asChild> <div className="text-left">
<Link href="/trials?status=scheduled">View All <ArrowRight className="ml-2 h-4 w-4" /></Link> <div className="font-semibold">Create New Study</div>
<div className="text-muted-foreground text-xs font-normal">
Design a new experiment protocol
</div>
</div>
<ArrowRight className="text-muted-foreground group-hover:text-primary ml-auto h-4 w-4 opacity-0 transition-all group-hover:opacity-100" />
</Link>
</Button>
<Button
variant="outline"
className="group h-auto justify-start px-4 py-4"
asChild
>
<Link href="/studies">
<div className="bg-secondary mr-4 rounded-full p-2">
<Search className="text-foreground h-5 w-5" />
</div>
<div className="text-left">
<div className="font-semibold">Browse Studies</div>
<div className="text-muted-foreground text-xs font-normal">
Find and manage existing studies
</div>
</div>
</Link>
</Button>
<Button
variant="outline"
className="group h-auto justify-start px-4 py-4"
asChild
>
<Link href="/trials">
<div className="mr-4 rounded-full bg-emerald-500/10 p-2">
<Activity className="h-5 w-5 text-emerald-600" />
</div>
<div className="text-left">
<div className="font-semibold">Monitor Active Trials</div>
<div className="text-muted-foreground text-xs font-normal">
Jump into the Wizard Interface
</div>
</div>
</Link>
</Button>
</CardContent>
</Card>
{/* Recent Activity Card */}
<Card
id="tour-recent-activity"
className="border-muted/40 col-span-4 shadow-sm"
>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>
Your latest interactions across the platform
</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px] pr-4">
<div className="space-y-4">
{recentActivity?.map((activity) => {
let eventColor = "bg-primary/30 ring-background";
let Icon = Activity;
if (activity.type === "trial_started") {
eventColor = "bg-blue-500 ring-blue-100 dark:ring-blue-900";
Icon = PlayCircle;
} else if (activity.type === "trial_completed") {
eventColor =
"bg-green-500 ring-green-100 dark:ring-green-900";
Icon = CheckCircle;
} else if (activity.type === "error") {
eventColor = "bg-red-500 ring-red-100 dark:ring-red-900";
Icon = AlertTriangle;
} else if (activity.type === "intervention") {
eventColor =
"bg-orange-500 ring-orange-100 dark:ring-orange-900";
Icon = Gamepad2;
} else if (activity.type === "annotation") {
eventColor =
"bg-yellow-500 ring-yellow-100 dark:ring-yellow-900";
Icon = MessageSquare;
}
return (
<div
key={activity.id}
className="border-muted-foreground/20 relative border-l pb-4 pl-6 last:border-0"
>
<span
className={`absolute top-0 left-[-9px] flex h-4 w-4 items-center justify-center rounded-full ring-4 ${eventColor}`}
>
<Icon className="h-2.5 w-2.5 text-white" />
</span>
<div className="mb-0.5 text-sm leading-none font-medium">
{activity.title}
</div>
<div className="text-muted-foreground mb-1 text-xs">
{activity.description}
</div>
<div className="text-muted-foreground/70 font-mono text-[10px] uppercase">
{formatDistanceToNow(new Date(activity.time), {
addSuffix: true,
})}
</div>
</div>
);
})}
{!recentActivity?.length && (
<div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-center">
<Clock className="mb-3 h-10 w-10 opacity-20" />
<p>No recent activity recorded.</p>
<p className="mt-1 text-xs">
Start a trial to see experiment events stream here.
</p>
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
{/* Live Trials */}
<Card
id="tour-live-trials"
className={`${liveTrials && liveTrials.length > 0 ? "border-primary bg-primary/5 shadow-sm" : "border-muted/40"} col-span-4 transition-colors duration-500`}
>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
Live Sessions
{liveTrials && liveTrials.length > 0 && (
<span className="relative flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex h-3 w-3 rounded-full bg-red-500"></span>
</span>
)}
</CardTitle>
<CardDescription>
Currently running trials in the Wizard interface
</CardDescription>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href="/trials">
View All <ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
{!liveTrials?.length ? (
<div className="border-muted-foreground/30 animate-in fade-in-50 bg-background/50 flex h-[150px] flex-col items-center justify-center rounded-md border border-dashed text-center">
<Radio className="text-muted-foreground/50 mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm">
No trials are currently running.
</p>
<Button variant="link" size="sm" asChild className="mt-1">
<Link href="/trials">Start a Trial</Link>
</Button> </Button>
</div> </div>
</CardHeader> ) : (
<CardContent> <div className="space-y-4">
{!scheduledTrials?.length ? ( {liveTrials.map((trial) => (
<div className="flex h-[150px] flex-col items-center justify-center rounded-md border border-dashed text-center animate-in fade-in-50"> <div
<Calendar className="h-8 w-8 text-muted-foreground/50" /> key={trial.id}
<p className="mt-2 text-sm text-muted-foreground">No scheduled trials found.</p> className="border-primary/20 bg-background flex items-center justify-between rounded-lg border p-3 shadow-sm transition-all duration-200 hover:shadow"
<Button variant="link" size="sm" asChild className="mt-1"> >
<Link href="/trials/new">Schedule a Trial</Link> <div className="flex items-center gap-4">
</Button> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-400">
</div> <Radio className="h-5 w-5 animate-pulse" />
) : ( </div>
<div className="space-y-4"> <div>
{scheduledTrials.map((trial) => ( <p className="text-sm font-medium">
<div key={trial.id} className="flex items-center justify-between rounded-lg border p-3 hover:bg-muted/50 transition-colors"> {trial.participantCode}
<div className="flex items-center gap-4"> <span className="text-muted-foreground ml-2 text-xs font-normal">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400"> {trial.experimentName}
<Calendar className="h-5 w-5" /> </span>
</div> </p>
<div> <div className="text-muted-foreground flex items-center gap-2 text-xs">
<p className="font-medium text-sm"> <Clock className="h-3 w-3" />
{trial.participant.participantCode} Started{" "}
<span className="ml-2 text-muted-foreground font-normal text-xs"> {trial.experiment.name}</span> {trial.startedAt
</p> ? formatDistanceToNow(new Date(trial.startedAt), {
<div className="flex items-center gap-2 text-xs text-muted-foreground"> addSuffix: true,
<Clock className="h-3 w-3" /> })
{trial.scheduledAt ? format(trial.scheduledAt, "MMM d, h:mm a") : "Unscheduled"} : "just now"}
</div>
</div> </div>
</div> </div>
<Button size="sm" className="gap-2" asChild>
<Link href={`/wizard/${trial.id}`}>
<Play className="h-3.5 w-3.5" /> Start
</Link>
</Button>
</div> </div>
))} <Button
</div> size="sm"
)} className="bg-primary hover:bg-primary/90 gap-2"
</CardContent> asChild
</Card> >
<Link href={`/wizard/${trial.id}`}>
{/* Study Progress */} <Play className="h-3.5 w-3.5" /> Spectate / Jump In
<Card className="border-muted/40 shadow-sm"> </Link>
<CardHeader> </Button>
<CardTitle>Study Progress</CardTitle> </div>
<CardDescription> ))}
Completion tracking for active studies </div>
</CardDescription> )}
</CardHeader> </CardContent>
<CardContent className="space-y-6"> </Card>
{studyProgress?.map((study) => (
<div key={study.id} className="space-y-2"> {/* Study Progress */}
<div className="flex items-center justify-between text-sm"> <Card className="border-muted/40 col-span-3 shadow-sm">
<div className="font-medium">{study.name}</div> <CardHeader>
<div className="text-muted-foreground">{study.participants} / {study.totalParticipants} Participants</div> <CardTitle>Study Progress</CardTitle>
<CardDescription>
Completion tracking for active studies
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{studyProgress?.map((study) => (
<div key={study.id} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="font-medium">{study.name}</div>
<div className="text-muted-foreground">
{study.participants} / {study.totalParticipants}{" "}
Participants
</div> </div>
<Progress value={study.progress} className="h-2" />
</div> </div>
))} <Progress value={study.progress} className="h-2" />
{!studyProgress?.length && ( </div>
<p className="text-sm text-muted-foreground text-center py-4">No active studies to track.</p> ))}
)} {!studyProgress?.length && (
</CardContent> <p className="text-muted-foreground py-4 text-center text-sm">
</Card> No active studies to track.
</p>
</div> )}
</CardContent>
{/* Side Column: Recent Activity & Quick Actions */} </Card>
<div className="col-span-3 space-y-4">
{/* Quick Actions */}
<div className="grid grid-cols-2 gap-4">
<Button variant="outline" className="h-24 flex-col gap-2 hover:border-primary/50 hover:bg-primary/5" asChild>
<Link href="/experiments/new">
<Bot className="h-6 w-6 mb-1" />
<span>New Experim.</span>
</Link>
</Button>
<Button variant="outline" className="h-24 flex-col gap-2 hover:border-primary/50 hover:bg-primary/5" asChild>
<Link href="/trials/new">
<PlayCircle className="h-6 w-6 mb-1" />
<span>Run Trial</span>
</Link>
</Button>
</div>
{/* Recent Activity */}
<Card id="tour-recent-activity" className="border-muted/40 shadow-sm h-full">
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px] pr-4">
<div className="space-y-4">
{recentActivity?.map((activity) => (
<div key={activity.id} className="relative pl-4 pb-1 border-l last:border-0 border-muted-foreground/20">
<span className="absolute left-[-5px] top-1 h-2.5 w-2.5 rounded-full bg-primary/30 ring-4 ring-background" />
<div className="mb-1 text-sm font-medium leading-none">{activity.title}</div>
<div className="text-xs text-muted-foreground mb-1">{activity.description}</div>
<div className="text-[10px] text-muted-foreground/70 uppercase">
{formatDistanceToNow(activity.time, { addSuffix: true })}
</div>
</div>
))}
{!recentActivity?.length && (
<p className="text-sm text-muted-foreground text-center py-8">No recent activity.</p>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</div> </div>
</div> </div>
); );
@@ -294,24 +454,30 @@ function StatsCard({
icon: Icon, icon: Icon,
description, description,
trend, trend,
iconColor,
}: { }: {
title: string; title: string;
value: string | number; value: string | number;
icon: React.ElementType; icon: React.ElementType;
description: string; description: string;
trend?: string; trend?: string;
iconColor?: string;
}) { }) {
return ( return (
<Card className="border-muted/40 shadow-sm"> <Card className="border-muted/40 hover:border-primary/20 shadow-sm transition-all duration-200 hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle> <CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" /> <Icon className={`h-4 w-4 ${iconColor || "text-muted-foreground"}`} />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{value}</div> <div className="text-2xl font-bold">{value}</div>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs">
{description} {description}
{trend && <span className="ml-1 text-green-600 dark:text-green-400 font-medium">{trend}</span>} {trend && (
<span className="ml-1 font-medium text-green-600 dark:text-green-400">
{trend}
</span>
)}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -16,6 +16,7 @@ import {
PlayCircle, PlayCircle,
Settings2, Settings2,
Share2, Share2,
Sparkles,
} from "lucide-react"; } from "lucide-react";
export default async function Home() { export default async function Home() {
@@ -26,9 +27,9 @@ export default async function Home() {
} }
return ( return (
<div className="flex min-h-screen flex-col bg-background text-foreground"> <div className="bg-background text-foreground flex min-h-screen flex-col">
{/* Navbar */} {/* Navbar */}
<header className="sticky top-0 z-50 w-full border-b bg-background/80 backdrop-blur-sm"> <header className="bg-background/80 sticky top-0 z-50 w-full border-b backdrop-blur-sm">
<div className="container mx-auto flex h-16 items-center justify-between px-4"> <div className="container mx-auto flex h-16 items-center justify-between px-4">
<Logo iconSize="md" showText={true} /> <Logo iconSize="md" showText={true} />
<nav className="flex items-center gap-4"> <nav className="flex items-center gap-4">
@@ -38,7 +39,7 @@ export default async function Home() {
<Button variant="ghost" asChild className="hidden sm:inline-flex"> <Button variant="ghost" asChild className="hidden sm:inline-flex">
<Link href="#architecture">Architecture</Link> <Link href="#architecture">Architecture</Link>
</Button> </Button>
<div className="h-6 w-px bg-border hidden sm:block" /> <div className="bg-border hidden h-6 w-px sm:block" />
<Button variant="ghost" asChild> <Button variant="ghost" asChild>
<Link href="/auth/signin">Sign In</Link> <Link href="/auth/signin">Sign In</Link>
</Button> </Button>
@@ -53,11 +54,15 @@ export default async function Home() {
{/* Hero Section */} {/* Hero Section */}
<section className="relative overflow-hidden pt-20 pb-32 md:pt-32"> <section className="relative overflow-hidden pt-20 pb-32 md:pt-32">
{/* Background Gradients */} {/* Background Gradients */}
<div className="absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full bg-primary/20 blur-3xl opacity-30 dark:opacity-20" /> <div className="bg-primary/20 absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full opacity-30 blur-3xl dark:opacity-20" />
<div className="container mx-auto flex flex-col items-center px-4 text-center"> <div className="container mx-auto flex flex-col items-center px-4 text-center">
<Badge variant="secondary" className="mb-6 rounded-full px-4 py-1.5 text-sm font-medium"> <Badge
The Modern Standard for HRI Research variant="secondary"
className="mb-6 rounded-full px-4 py-1.5 text-sm font-medium"
>
<Sparkles className="mr-2 h-4 w-4 text-yellow-500" />
The Modern Standard for HRI Research
</Badge> </Badge>
<h1 className="max-w-4xl text-5xl font-extrabold tracking-tight sm:text-6xl md:text-7xl"> <h1 className="max-w-4xl text-5xl font-extrabold tracking-tight sm:text-6xl md:text-7xl">
@@ -67,7 +72,7 @@ export default async function Home() {
</span> </span>
</h1> </h1>
<p className="mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl"> <p className="text-muted-foreground mt-6 max-w-2xl text-lg md:text-xl">
HRIStudio is the open-source platform that bridges the gap between HRIStudio is the open-source platform that bridges the gap between
ease of use and scientific rigor. Design, execute, and analyze ease of use and scientific rigor. Design, execute, and analyze
human-robot interaction experiments with zero friction. human-robot interaction experiments with zero friction.
@@ -80,22 +85,32 @@ export default async function Home() {
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
</Link> </Link>
</Button> </Button>
<Button size="lg" variant="outline" className="h-12 px-8 text-base" asChild> <Button
<Link href="https://github.com/robolab/hristudio" target="_blank"> size="lg"
variant="outline"
className="h-12 px-8 text-base"
asChild
>
<Link
href="https://github.com/robolab/hristudio"
target="_blank"
>
View on GitHub View on GitHub
</Link> </Link>
</Button> </Button>
</div> </div>
{/* Mockup / Visual Interest */} {/* Mockup / Visual Interest */}
<div className="relative mt-20 w-full max-w-5xl rounded-xl border bg-background/50 p-2 shadow-2xl backdrop-blur-sm lg:rounded-2xl lg:p-4"> <div className="bg-background/50 relative mt-20 w-full max-w-5xl rounded-xl border p-2 shadow-2xl backdrop-blur-sm lg:rounded-2xl lg:p-4">
<div className="absolute inset-x-0 -top-px mx-auto h-px w-3/4 bg-gradient-to-r from-transparent via-foreground/20 to-transparent" /> <div className="via-foreground/20 absolute inset-x-0 -top-px mx-auto h-px w-3/4 bg-gradient-to-r from-transparent to-transparent" />
<div className="aspect-[16/9] w-full overflow-hidden rounded-lg border bg-muted/50 flex items-center justify-center relative"> <div className="bg-muted/50 relative flex aspect-[16/9] w-full items-center justify-center overflow-hidden rounded-lg border">
{/* Placeholder for actual app screenshot */} {/* Placeholder for actual app screenshot */}
<div className="absolute inset-0 bg-gradient-to-tr from-blue-500/10 to-violet-500/10" /> <div className="absolute inset-0 bg-gradient-to-tr from-blue-500/10 to-violet-500/10" />
<div className="text-center p-8"> <div className="p-8 text-center">
<LayoutTemplate className="w-16 h-16 mx-auto text-muted-foreground/50 mb-4" /> <LayoutTemplate className="text-muted-foreground/50 mx-auto mb-4 h-16 w-16" />
<p className="text-muted-foreground font-medium">Interactive Experiment Designer</p> <p className="text-muted-foreground font-medium">
Interactive Experiment Designer
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -105,13 +120,17 @@ export default async function Home() {
{/* Features Bento Grid */} {/* Features Bento Grid */}
<section id="features" className="container mx-auto px-4 py-24"> <section id="features" className="container mx-auto px-4 py-24">
<div className="mb-12 text-center"> <div className="mb-12 text-center">
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">Everything You Need</h2> <h2 className="text-3xl font-bold tracking-tight md:text-4xl">
<p className="mt-4 text-lg text-muted-foreground">Built for the specific needs of HRI researchers and wizards.</p> Everything You Need
</h2>
<p className="text-muted-foreground mt-4 text-lg">
Built for the specific needs of HRI researchers and wizards.
</p>
</div> </div>
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4 lg:grid-rows-2"> <div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4 lg:grid-rows-2">
{/* Visual Designer - Large Item */} {/* Visual Designer - Large Item */}
<Card className="col-span-1 md:col-span-2 lg:col-span-2 row-span-2 flex flex-col overflow-hidden bg-gradient-to-br from-blue-500/5 to-violet-500/5 dark:from-blue-900/10 dark:to-violet-900/10"> <Card className="col-span-1 row-span-2 flex flex-col overflow-hidden bg-gradient-to-br from-blue-500/5 to-violet-500/5 md:col-span-2 lg:col-span-2 dark:from-blue-900/10 dark:to-violet-900/10">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<LayoutTemplate className="h-5 w-5 text-blue-500" /> <LayoutTemplate className="h-5 w-5 text-blue-500" />
@@ -120,16 +139,19 @@ export default async function Home() {
</CardHeader> </CardHeader>
<CardContent className="flex-1"> <CardContent className="flex-1">
<p className="text-muted-foreground mb-6"> <p className="text-muted-foreground mb-6">
Construct complex branching narratives without writing a single line of code. Construct complex branching narratives without writing a
Our node-based editor handles logic, timing, and robot actions automatically. single line of code. Our node-based editor handles logic,
timing, and robot actions automatically.
</p> </p>
<div className="rounded-lg border bg-background/50 p-4 h-full min-h-[200px] flex items-center justify-center shadow-inner"> <div className="bg-background/50 flex h-full min-h-[200px] items-center justify-center rounded-lg border p-4 shadow-inner">
<div className="flex gap-2 items-center text-sm text-muted-foreground"> <div className="text-muted-foreground flex items-center gap-2 text-sm">
<span className="rounded bg-accent p-2">Start</span> <span className="bg-accent rounded p-2">Start</span>
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
<span className="rounded bg-primary/10 p-2 border border-primary/20 text-primary font-medium">Robot: Greet</span> <span className="bg-primary/10 border-primary/20 text-primary rounded border p-2 font-medium">
Robot: Greet
</span>
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
<span className="rounded bg-accent p-2">Wait: 5s</span> <span className="bg-accent rounded p-2">Wait: 5s</span>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -145,14 +167,15 @@ export default async function Home() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Switch between robots instantly. Whether it's a NAO, Pepper, or a custom ROS2 bot, Switch between robots instantly. Whether it's a NAO, Pepper,
your experiment logic remains strictly separated from hardware implementation. or a custom ROS2 bot, your experiment logic remains strictly
separated from hardware implementation.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
{/* Role Based */} {/* Role Based */}
<Card className="col-span-1 md:col-span-1 lg:col-span-1 bg-muted/30"> <Card className="bg-muted/30 col-span-1 md:col-span-1 lg:col-span-1">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Lock className="h-4 w-4 text-orange-500" /> <Lock className="h-4 w-4 text-orange-500" />
@@ -160,14 +183,15 @@ export default async function Home() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
Granular permissions for Principal Investigators, Wizards, and Observers. Granular permissions for Principal Investigators, Wizards, and
Observers.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
{/* Data Logging */} {/* Data Logging */}
<Card className="col-span-1 md:col-span-1 lg:col-span-1 bg-muted/30"> <Card className="bg-muted/30 col-span-1 md:col-span-1 lg:col-span-1">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Database className="h-4 w-4 text-rose-500" /> <Database className="h-4 w-4 text-rose-500" />
@@ -175,8 +199,9 @@ export default async function Home() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
Every wizard action, automated response, and sensor reading is time-stamped and logged. Every wizard action, automated response, and sensor reading is
time-stamped and logged.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -184,41 +209,56 @@ export default async function Home() {
</section> </section>
{/* Architecture Section */} {/* Architecture Section */}
<section id="architecture" className="border-t bg-muted/30 py-24"> <section id="architecture" className="bg-muted/30 border-t py-24">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="grid gap-12 lg:grid-cols-2 lg:gap-8 items-center"> <div className="grid items-center gap-12 lg:grid-cols-2 lg:gap-8">
<div> <div>
<h2 className="text-3xl font-bold tracking-tight">Enterprise-Grade Architecture</h2> <h2 className="text-3xl font-bold tracking-tight">
<p className="mt-4 text-lg text-muted-foreground"> Enterprise-Grade Architecture
Designed for reliability and scale. HRIStudio uses a modern stack to ensure your data is safe and your experiments run smoothly. </h2>
<p className="text-muted-foreground mt-4 text-lg">
Designed for reliability and scale. HRIStudio uses a modern
stack to ensure your data is safe and your experiments run
smoothly.
</p> </p>
<div className="mt-8 space-y-4"> <div className="mt-8 space-y-4">
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-background border shadow-sm"> <div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
<Network className="h-5 w-5 text-primary" /> <Network className="text-primary h-5 w-5" />
</div> </div>
<div> <div>
<h3 className="font-semibold">3-Layer Design</h3> <h3 className="font-semibold">3-Layer Design</h3>
<p className="text-muted-foreground">Clear separation between UI, Data, and Hardware layers for maximum stability.</p> <p className="text-muted-foreground">
Clear separation between UI, Data, and Hardware layers
for maximum stability.
</p>
</div> </div>
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-background border shadow-sm"> <div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
<Share2 className="h-5 w-5 text-primary" /> <Share2 className="text-primary h-5 w-5" />
</div> </div>
<div> <div>
<h3 className="font-semibold">Collaborative by Default</h3> <h3 className="font-semibold">
<p className="text-muted-foreground">Real-time state synchronization allows multiple researchers to monitor a single trial.</p> Collaborative by Default
</h3>
<p className="text-muted-foreground">
Real-time state synchronization allows multiple
researchers to monitor a single trial.
</p>
</div> </div>
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-background border shadow-sm"> <div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
<Settings2 className="h-5 w-5 text-primary" /> <Settings2 className="text-primary h-5 w-5" />
</div> </div>
<div> <div>
<h3 className="font-semibold">ROS2 Integration</h3> <h3 className="font-semibold">ROS2 Integration</h3>
<p className="text-muted-foreground">Native support for ROS2 nodes, topics, and actions right out of the box.</p> <p className="text-muted-foreground">
Native support for ROS2 nodes, topics, and actions right
out of the box.
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -226,34 +266,46 @@ export default async function Home() {
<div className="relative mx-auto w-full max-w-[500px]"> <div className="relative mx-auto w-full max-w-[500px]">
{/* Abstract representation of architecture */} {/* Abstract representation of architecture */}
<div className="space-y-4 relative z-10"> <div className="relative z-10 space-y-4">
<Card className="border-blue-500/20 bg-blue-500/5 relative left-0 hover:left-2 transition-all cursor-default"> <Card className="relative left-0 cursor-default border-blue-500/20 bg-blue-500/5 transition-all hover:left-2">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-blue-600 dark:text-blue-400 text-sm font-mono">APP LAYER</CardTitle> <CardTitle className="font-mono text-sm text-blue-600 dark:text-blue-400">
APP LAYER
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="font-semibold">Next.js Dashboard + Experiment Designer</p> <p className="font-semibold">
Next.js Dashboard + Experiment Designer
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-violet-500/20 bg-violet-500/5 relative left-4 hover:left-6 transition-all cursor-default"> <Card className="relative left-4 cursor-default border-violet-500/20 bg-violet-500/5 transition-all hover:left-6">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-violet-600 dark:text-violet-400 text-sm font-mono">DATA LAYER</CardTitle> <CardTitle className="font-mono text-sm text-violet-600 dark:text-violet-400">
DATA LAYER
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="font-semibold">PostgreSQL + MinIO + TRPC API</p> <p className="font-semibold">
PostgreSQL + MinIO + TRPC API
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-green-500/20 bg-green-500/5 relative left-8 hover:left-10 transition-all cursor-default"> <Card className="relative left-8 cursor-default border-green-500/20 bg-green-500/5 transition-all hover:left-10">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-green-600 dark:text-green-400 text-sm font-mono">HARDWARE LAYER</CardTitle> <CardTitle className="font-mono text-sm text-green-600 dark:text-green-400">
HARDWARE LAYER
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="font-semibold">ROS2 Bridge + Robot Plugins</p> <p className="font-semibold">
ROS2 Bridge + Robot Plugins
</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Decorative blobs */} {/* Decorative blobs */}
<div className="absolute top-1/2 left-1/2 -z-10 h-[300px] w-[300px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/10 blur-3xl" /> <div className="bg-primary/10 absolute top-1/2 left-1/2 -z-10 h-[300px] w-[300px] -translate-x-1/2 -translate-y-1/2 rounded-full blur-3xl" />
</div> </div>
</div> </div>
</div> </div>
@@ -261,31 +313,46 @@ export default async function Home() {
{/* CTA Section */} {/* CTA Section */}
<section className="container mx-auto px-4 py-24 text-center"> <section className="container mx-auto px-4 py-24 text-center">
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">Ready to upgrade your lab?</h2> <h2 className="text-3xl font-bold tracking-tight md:text-4xl">
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground"> Ready to upgrade your lab?
Join the community of researchers building the future of HRI with reproducible, open-source tools. </h2>
<p className="text-muted-foreground mx-auto mt-4 max-w-2xl text-lg">
Join the community of researchers building the future of HRI with
reproducible, open-source tools.
</p> </p>
<div className="mt-8"> <div className="mt-8">
<Button size="lg" className="h-12 px-8 text-base shadow-lg shadow-primary/20" asChild> <Button
size="lg"
className="shadow-primary/20 h-12 px-8 text-base shadow-lg"
asChild
>
<Link href="/auth/signup">Get Started for Free</Link> <Link href="/auth/signup">Get Started for Free</Link>
</Button> </Button>
</div> </div>
</section> </section>
</main> </main>
<footer className="border-t bg-muted/40 py-12"> <footer className="bg-muted/40 border-t py-12">
<div className="container mx-auto px-4 flex flex-col items-center justify-between gap-6 md:flex-row text-center md:text-left"> <div className="container mx-auto flex flex-col items-center justify-between gap-6 px-4 text-center md:flex-row md:text-left">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Logo iconSize="sm" showText={true} /> <Logo iconSize="sm" showText={true} />
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
&copy; {new Date().getFullYear()} HRIStudio. All rights reserved. &copy; {new Date().getFullYear()} HRIStudio. All rights reserved.
</p> </p>
</div> </div>
<div className="flex gap-6 text-sm text-muted-foreground"> <div className="text-muted-foreground flex gap-6 text-sm">
<Link href="#" className="hover:text-foreground">Privacy</Link> <Link href="#" className="hover:text-foreground">
<Link href="#" className="hover:text-foreground">Terms</Link> Privacy
<Link href="#" className="hover:text-foreground">GitHub</Link> </Link>
<Link href="#" className="hover:text-foreground">Documentation</Link> <Link href="#" className="hover:text-foreground">
Terms
</Link>
<Link href="#" className="hover:text-foreground">
GitHub
</Link>
<Link href="#" className="hover:text-foreground">
Documentation
</Link>
</div> </div>
</div> </div>
</footer> </footer>

View File

@@ -1,11 +1,11 @@
import Link from "next/link"; import Link from "next/link";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle CardTitle,
} from "~/components/ui/card"; } from "~/components/ui/card";
import { auth } from "~/server/auth"; import { auth } from "~/server/auth";

View File

@@ -4,7 +4,9 @@ import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { import {
getAvailableRoles, getRoleColor, getRolePermissions getAvailableRoles,
getRoleColor,
getRolePermissions,
} from "~/lib/auth-client"; } from "~/lib/auth-client";
export function RoleManagement() { export function RoleManagement() {

View File

@@ -2,13 +2,12 @@
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { api } from "~/trpc/react";
export function SystemStats() { export function SystemStats() {
// TODO: Implement admin.getSystemStats API endpoint const { data: stats, isLoading } = api.admin.getSystemStats.useQuery({});
// const { data: stats, isLoading } = api.admin.getSystemStats.useQuery({});
const isLoading = false;
if (isLoading) { if (isLoading || !stats) {
return ( return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
@@ -26,19 +25,30 @@ export function SystemStats() {
); );
} }
// Mock data for now since we don't have the actual admin router implemented const formatBytes = (bytes: number) => {
const mockStats = { if (bytes === 0) return "0 B";
totalUsers: 42, const k = 1024;
totalStudies: 15, const sizes = ["B", "KB", "MB", "GB", "TB"];
totalExperiments: 38, const i = Math.floor(Math.log(bytes) / Math.log(k));
totalTrials: 127, return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
activeTrials: 3,
systemHealth: "healthy",
uptime: "7 days, 14 hours",
storageUsed: "2.3 GB",
}; };
const displayStats = mockStats; const formatUptime = (seconds: number) => {
const d = Math.floor(seconds / (3600 * 24));
const h = Math.floor((seconds % (3600 * 24)) / 3600);
return `${d} days, ${h} hours`;
};
const displayStats = {
totalUsers: stats.users.total,
totalStudies: stats.studies.total,
totalExperiments: stats.experiments.total,
totalTrials: stats.trials.total,
activeTrials: stats.trials.running,
systemHealth: "healthy",
uptime: formatUptime(stats.system.uptime),
storageUsed: formatBytes(stats.storage.totalSize),
};
return ( return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">

View File

@@ -0,0 +1,341 @@
"use client";
import {
type ColumnDef,
type ColumnFiltersState,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { useState } from "react";
import {
ArrowUpDown,
MoreHorizontal,
Calendar,
Clock,
Activity,
Eye,
Video,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Badge } from "~/components/ui/badge";
import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
export type AnalyticsTrial = {
id: string;
sessionNumber: number;
status: string;
createdAt: Date;
startedAt: Date | null;
completedAt: Date | null;
duration: number | null;
eventCount: number;
mediaCount: number;
experimentId: string;
participant: {
participantCode: string;
};
experiment: {
name: string;
studyId: string;
};
};
export const columns: ColumnDef<AnalyticsTrial>[] = [
{
accessorKey: "sessionNumber",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Session
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => (
<div className="text-center font-mono">
#{row.getValue("sessionNumber")}
</div>
),
},
{
accessorKey: "participant.participantCode",
id: "participantCode",
header: "Participant",
cell: ({ row }) => (
<div className="font-medium">
{row.original.participant?.participantCode ?? "Unknown"}
</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.getValue("status") as string;
return (
<Badge
variant="outline"
className={`capitalize ${
status === "completed"
? "border-green-500/20 bg-green-500/10 text-green-500"
: status === "in_progress"
? "border-blue-500/20 bg-blue-500/10 text-blue-500"
: "border-slate-500/20 bg-slate-500/10 text-slate-500"
}`}
>
{status.replace("_", " ")}
</Badge>
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Date
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const date = new Date(row.getValue("createdAt"));
return (
<div className="flex flex-col">
<span className="text-sm">{date.toLocaleDateString()}</span>
<span className="text-muted-foreground text-xs">
{formatDistanceToNow(date, { addSuffix: true })}
</span>
</div>
);
},
},
{
accessorKey: "duration",
header: "Duration",
cell: ({ row }) => {
const duration = row.getValue("duration") as number | null;
if (!duration) return <span className="text-muted-foreground">-</span>;
const m = Math.floor(duration / 60);
const s = Math.floor(duration % 60);
return <div className="font-mono">{`${m}m ${s}s`}</div>;
},
},
{
accessorKey: "eventCount",
header: "Events",
cell: ({ row }) => {
return (
<div className="flex items-center gap-1">
<Activity className="text-muted-foreground h-3 w-3" />
<span>{row.getValue("eventCount")}</span>
</div>
);
},
},
{
accessorKey: "mediaCount",
header: "Media",
cell: ({ row }) => {
const count = row.getValue("mediaCount") as number;
if (count === 0) return <span className="text-muted-foreground">-</span>;
return (
<div className="flex items-center gap-1">
<Video className="text-muted-foreground h-3 w-3" />
<span>{count}</span>
</div>
);
},
},
{
id: "actions",
cell: ({ row }) => {
const trial = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link
href={`/studies/${trial.experiment?.studyId}/trials/${trial.id}/analysis`}
>
<Eye className="mr-2 h-4 w-4" />
View Analysis
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href={`/experiments/${trial.experimentId}/trials/${trial.id}`}
>
View Trial Details
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
interface StudyAnalyticsDataTableProps {
data: AnalyticsTrial[];
}
export function StudyAnalyticsDataTable({
data,
}: StudyAnalyticsDataTableProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return (
<div className="w-full" id="tour-analytics-table">
<div className="flex items-center py-4">
<Input
placeholder="Filter participants..."
value={
(table.getColumn("participantCode")?.getFilterValue() as string) ??
""
}
onChange={(event) =>
table
.getColumn("participantCode")
?.setFilterValue(event.target.value)
}
className="max-w-sm"
id="tour-analytics-filter"
/>
</div>
<div className="bg-card rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="text-muted-foreground flex-1 text-sm">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,125 +0,0 @@
"use client";
import { Activity, Calendar, CheckCircle, FlaskConical } from "lucide-react";
import { DashboardOverviewLayout } from "~/components/ui/page-layout";
interface DashboardContentProps {
userName: string;
userRole: string;
totalStudies: number;
activeTrials: number;
scheduledTrials: number;
completedToday: number;
canControl: boolean;
canManage: boolean;
_recentTrials: unknown[];
}
export function DashboardContent({
userName,
userRole,
totalStudies,
activeTrials,
scheduledTrials,
completedToday,
canControl,
canManage,
_recentTrials,
}: DashboardContentProps) {
const getWelcomeMessage = () => {
switch (userRole) {
case "wizard":
return "Ready to control trials";
case "researcher":
return "Your research platform awaits";
case "administrator":
return "System management dashboard";
default:
return "Welcome to HRIStudio";
}
};
const quickActions = [
...(canManage
? [
{
title: "Create Study",
description: "Start a new research study",
icon: FlaskConical,
href: "/studies/new",
variant: "primary" as const,
},
]
: []),
...(canControl
? [
{
title: "Browse Studies",
description: "View and manage studies",
icon: Calendar,
href: "/studies",
variant: "default" as const,
},
]
: []),
];
const stats = [
{
title: "Studies",
value: totalStudies,
description: "Research studies",
icon: FlaskConical,
variant: "primary" as const,
action: {
label: "View All",
href: "/studies",
},
},
{
title: "Active Trials",
value: activeTrials,
description: "Currently running",
icon: Activity,
variant: "success" as const,
...(canControl && {
action: {
label: "View",
href: "/studies",
},
}),
},
{
title: "Scheduled",
value: scheduledTrials,
description: "Upcoming trials",
icon: Calendar,
variant: "default" as const,
},
{
title: "Completed Today",
value: completedToday,
description: "Finished trials",
icon: CheckCircle,
variant: "success" as const,
},
];
const alerts: never[] = [];
const recentActivity = null;
return (
<DashboardOverviewLayout
title={`${getWelcomeMessage()}, ${userName}`}
description="Monitor your HRI research activities and manage ongoing studies"
userName={userName}
userRole={userRole}
breadcrumb={[{ label: "Dashboard" }]}
quickActions={quickActions}
stats={stats}
alerts={alerts}
recentActivity={recentActivity}
/>
);
}

View File

@@ -1,27 +1,31 @@
"use client"; "use client";
import React, { useEffect } from "react"; import React, { useEffect, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { signOut, useSession } from "next-auth/react"; import { signOut, useSession } from "next-auth/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
BarChart3, BarChart3,
BookOpen,
Building, Building,
ChevronDown, ChevronDown,
FlaskConical, FlaskConical,
Home, Home,
LogOut, LogOut,
MoreHorizontal, MoreHorizontal,
PlayCircle,
Puzzle, Puzzle,
Settings, Settings,
TestTube, TestTube,
User, User,
UserCheck, UserCheck,
Users, Users,
FileText,
} from "lucide-react"; } from "lucide-react";
import { useSidebar } from "~/components/ui/sidebar"; import { useSidebar } from "~/components/ui/sidebar";
import { useTour } from "~/components/onboarding/TourProvider";
import { import {
DropdownMenu, DropdownMenu,
@@ -93,6 +97,11 @@ const studyWorkItems = [
url: "/experiments", url: "/experiments",
icon: FlaskConical, icon: FlaskConical,
}, },
{
title: "Forms",
url: "/forms",
icon: FileText,
},
{ {
title: "Analytics", title: "Analytics",
url: "/analytics", url: "/analytics",
@@ -113,6 +122,20 @@ const adminItems = [
}, },
]; ];
const helpItems = [
{
title: "Help Center",
url: "/help",
icon: BookOpen,
},
{
title: "Interactive Tour",
url: "#tour",
icon: PlayCircle,
action: "tour",
},
];
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> { interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
userRole?: string; userRole?: string;
} }
@@ -126,8 +149,39 @@ export function AppSidebar({
const isAdmin = userRole === "administrator"; const isAdmin = userRole === "administrator";
const { state: sidebarState } = useSidebar(); const { state: sidebarState } = useSidebar();
const isCollapsed = sidebarState === "collapsed"; const isCollapsed = sidebarState === "collapsed";
const { selectedStudyId, userStudies, selectStudy, refreshStudyData } = const {
useStudyManagement(); selectedStudyId,
userStudies,
selectStudy,
refreshStudyData,
isLoadingUserStudies,
} = useStudyManagement();
const { startTour, isTourActive } = useTour();
// Reference to track if we've already attempted auto-selection to avoid fighting with manual clearing
const hasAutoSelected = useRef(false);
// Auto-select most recently touched study if none selected
useEffect(() => {
// Only run if not loading, no study selected, and we have studies available
// And only run once per session (using ref) to allow user to clear selection if desired
if (
!isLoadingUserStudies &&
!selectedStudyId &&
userStudies.length > 0 &&
!hasAutoSelected.current
) {
// userStudies is sorted by updatedAt desc from the API, so the first one is the most recent
// userStudies is sorted by updatedAt desc from the API, so the first one is the most recent
const mostRecent = userStudies[0];
if (mostRecent) {
console.log("Auto-selecting most recent study:", mostRecent.name);
void selectStudy(mostRecent.id);
hasAutoSelected.current = true;
}
}
}, [isLoadingUserStudies, selectedStudyId, userStudies, selectStudy]);
// Debug API call // Debug API call
const { data: debugData } = api.dashboard.debug.useQuery(undefined, { const { data: debugData } = api.dashboard.debug.useQuery(undefined, {
@@ -261,6 +315,17 @@ export function AppSidebar({
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
{isTourActive && !isCollapsed && (
<div className="mt-1 px-3 pb-2">
<div className="bg-primary/10 text-primary border-primary/20 animate-in fade-in slide-in-from-top-2 flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium shadow-sm">
<span className="relative flex h-2 w-2">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full"></span>
</span>
Tutorial Active
</div>
</div>
)}
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
@@ -276,7 +341,10 @@ export function AppSidebar({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<SidebarMenuButton className="w-full" id="tour-sidebar-study-selector"> <SidebarMenuButton
className="w-full"
id="tour-sidebar-study-selector"
>
<Building className="h-4 w-4 flex-shrink-0" /> <Building className="h-4 w-4 flex-shrink-0" />
<span className="truncate"> <span className="truncate">
{selectedStudy?.name ?? "Select Study"} {selectedStudy?.name ?? "Select Study"}
@@ -325,7 +393,10 @@ export function AppSidebar({
) : ( ) : (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<SidebarMenuButton className="w-full" id="tour-sidebar-study-selector"> <SidebarMenuButton
className="w-full"
id="tour-sidebar-study-selector"
>
<Building className="h-4 w-4 flex-shrink-0" /> <Building className="h-4 w-4 flex-shrink-0" />
<span className="truncate"> <span className="truncate">
{selectedStudy?.name ?? "Select Study"} {selectedStudy?.name ?? "Select Study"}
@@ -520,6 +591,53 @@ export function AppSidebar({
)} )}
</SidebarContent> </SidebarContent>
{/* Help Section */}
<SidebarGroup>
<SidebarGroupLabel>Support</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{helpItems.map((item) => {
const isActive = pathname.startsWith(item.url);
const menuButton =
item.action === "tour" ? (
<SidebarMenuButton
onClick={() => startTour("full_platform")}
isActive={false}
>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</SidebarMenuButton>
) : (
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.url}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
);
return (
<SidebarMenuItem key={item.title}>
{isCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{menuButton}</TooltipTrigger>
<TooltipContent side="right" className="text-sm">
{item.title}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
menuButton
)}
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* Debug info moved to footer tooltip button */} {/* Debug info moved to footer tooltip button */}
<SidebarFooter> <SidebarFooter>

View File

@@ -87,33 +87,33 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
{ label: "Studies", href: "/studies" }, { label: "Studies", href: "/studies" },
...(selectedStudyId ...(selectedStudyId
? [ ? [
{ {
label: experiment?.study?.name ?? "Study", label: experiment?.study?.name ?? "Study",
href: `/studies/${selectedStudyId}`, href: `/studies/${selectedStudyId}`,
}, },
{ label: "Experiments", href: "/experiments" }, { label: "Experiments", href: "/experiments" },
...(mode === "edit" && experiment ...(mode === "edit" && experiment
? [ ? [
{ {
label: experiment.name, label: experiment.name,
href: `/studies/${selectedStudyId}/experiments/${experiment.id}`, href: `/studies/${selectedStudyId}/experiments/${experiment.id}`,
}, },
{ label: "Edit" }, { label: "Edit" },
] ]
: [{ label: "New Experiment" }]), : [{ label: "New Experiment" }]),
] ]
: [ : [
{ label: "Experiments", href: "/experiments" }, { label: "Experiments", href: "/experiments" },
...(mode === "edit" && experiment ...(mode === "edit" && experiment
? [ ? [
{ {
label: experiment.name, label: experiment.name,
href: `/studies/${experiment.studyId}/experiments/${experiment.id}`, href: `/studies/${experiment.studyId}/experiments/${experiment.id}`,
}, },
{ label: "Edit" }, { label: "Edit" },
] ]
: [{ label: "New Experiment" }]), : [{ label: "New Experiment" }]),
]), ]),
]; ];
useBreadcrumbsEffect(breadcrumbs); useBreadcrumbsEffect(breadcrumbs);
@@ -153,14 +153,18 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
...data, ...data,
estimatedDuration: data.estimatedDuration ?? undefined, estimatedDuration: data.estimatedDuration ?? undefined,
}); });
router.push(`/studies/${data.studyId}/experiments/${newExperiment.id}/designer`); router.push(
`/studies/${data.studyId}/experiments/${newExperiment.id}/designer`,
);
} else { } else {
const updatedExperiment = await updateExperimentMutation.mutateAsync({ const updatedExperiment = await updateExperimentMutation.mutateAsync({
id: experimentId!, id: experimentId!,
...data, ...data,
estimatedDuration: data.estimatedDuration ?? undefined, estimatedDuration: data.estimatedDuration ?? undefined,
}); });
router.push(`/studies/${experiment?.studyId ?? data.studyId}/experiments/${updatedExperiment.id}`); router.push(
`/studies/${experiment?.studyId ?? data.studyId}/experiments/${updatedExperiment.id}`,
);
} }
} catch (error) { } catch (error) {
setError( setError(

View File

@@ -1,7 +1,17 @@
"use client"; "use client";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { Calendar, FlaskConical, Plus, Settings, Users } from "lucide-react"; import {
Calendar,
FlaskConical,
Plus,
Settings,
Users,
FileEdit,
TestTube,
CheckCircle2,
Trash2,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
@@ -45,22 +55,22 @@ const statusConfig = {
draft: { draft: {
label: "Draft", label: "Draft",
className: "bg-gray-100 text-gray-800 hover:bg-gray-200", className: "bg-gray-100 text-gray-800 hover:bg-gray-200",
icon: "📝", icon: FileEdit,
}, },
testing: { testing: {
label: "Testing", label: "Testing",
className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200", className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
icon: "🧪", icon: TestTube,
}, },
ready: { ready: {
label: "Ready", label: "Ready",
className: "bg-green-100 text-green-800 hover:bg-green-200", className: "bg-green-100 text-green-800 hover:bg-green-200",
icon: "✅", icon: CheckCircle2,
}, },
deprecated: { deprecated: {
label: "Deprecated", label: "Deprecated",
className: "bg-red-100 text-red-800 hover:bg-red-200", className: "bg-red-100 text-red-800 hover:bg-red-200",
icon: "🗑️", icon: Trash2,
}, },
}; };
@@ -98,7 +108,7 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
</div> </div>
</div> </div>
<Badge className={statusInfo.className} variant="secondary"> <Badge className={statusInfo.className} variant="secondary">
<span className="mr-1">{statusInfo.icon}</span> <statusInfo.icon className="mr-1 h-3.5 w-3.5" />
{statusInfo.label} {statusInfo.label}
</Badge> </Badge>
</div> </div>
@@ -158,10 +168,16 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
{/* Actions */} {/* Actions */}
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
<Button asChild size="sm" className="flex-1"> <Button asChild size="sm" className="flex-1">
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>View Details</Link> <Link
href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}
>
View Details
</Link>
</Button> </Button>
<Button asChild size="sm" variant="outline" className="flex-1"> <Button asChild size="sm" variant="outline" className="flex-1">
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}> <Link
href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}
>
<Settings className="mr-1 h-3 w-3" /> <Settings className="mr-1 h-3 w-3" />
Design Design
</Link> </Link>

View File

@@ -1,7 +1,13 @@
"use client"; "use client";
import { type ColumnDef } from "@tanstack/react-table"; import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, LayoutTemplate, PlayCircle, Archive } from "lucide-react"; import {
ArrowUpDown,
MoreHorizontal,
Edit,
LayoutTemplate,
Trash2,
} from "lucide-react";
import * as React from "react"; import * as React from "react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
@@ -243,65 +249,53 @@ export const columns: ColumnDef<Experiment>[] = [
{ {
id: "actions", id: "actions",
enableHiding: false, enableHiding: false,
cell: ({ row }) => { cell: ({ row }) => <ExperimentActions experiment={row.original} />,
const experiment = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(experiment.id)}
>
<Copy className="mr-2 h-4 w-4" />
Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>
<Eye className="mr-2 h-4 w-4" />
Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
<LayoutTemplate className="mr-2 h-4 w-4" />
Designer
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={`/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`}
>
<PlayCircle className="mr-2 h-4 w-4" />
Start Trial
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600">
<Archive className="mr-2 h-4 w-4" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
}, },
]; ];
function ExperimentActions({ experiment }: { experiment: Experiment }) {
const utils = api.useUtils();
const deleteMutation = api.experiments.delete.useMutation({
onSuccess: () => {
utils.experiments.list.invalidate();
},
});
return (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
asChild
className="text-muted-foreground hover:text-primary h-8 w-8"
title="Open Designer"
>
<Link
href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}
>
<LayoutTemplate className="h-4 w-4" />
<span className="sr-only">Design</span>
</Link>
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
if (confirm("Are you sure you want to delete this experiment?")) {
deleteMutation.mutate({ id: experiment.id });
}
}}
className="text-muted-foreground hover:text-destructive h-8 w-8"
title="Delete Experiment"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
</div>
);
}
export function ExperimentsTable() { export function ExperimentsTable() {
const { selectedStudyId } = useStudyContext(); const { selectedStudyId } = useStudyContext();

View File

@@ -1,13 +1,18 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import type { ActionDefinition } from "~/lib/experiment-designer/types"; import type {
ActionDefinition,
ExperimentAction,
} from "~/lib/experiment-designer/types";
import corePluginDef from "~/plugins/definitions/hristudio-core.json";
import wozPluginDef from "~/plugins/definitions/hristudio-woz.json";
/** /**
* ActionRegistry * ActionRegistry
* *
* Central singleton for loading and serving action definitions from: * Central singleton for loading and serving action definitions from:
* - Core system action JSON manifests (served from /hristudio-core/plugins/*.json) * - Core system action JSON manifests (hristudio-core, hristudio-woz)
* - Study-installed plugin action definitions (ROS2 / REST / internal transports) * - Study-installed plugin action definitions (ROS2 / REST / internal transports)
* *
* Responsibilities: * Responsibilities:
@@ -15,12 +20,6 @@ import type { ActionDefinition } from "~/lib/experiment-designer/types";
* - Provenance retention (core vs plugin, plugin id/version, robot id) * - Provenance retention (core vs plugin, plugin id/version, robot id)
* - Parameter schema → UI parameter mapping (primitive only for now) * - Parameter schema → UI parameter mapping (primitive only for now)
* - Fallback action population if core load fails (ensures minimal functionality) * - Fallback action population if core load fails (ensures minimal functionality)
*
* Notes:
* - The registry is client-side only (designer runtime); server performs its own
* validation & compilation using persisted action instances (never trusts client).
* - Action IDs for plugins are namespaced: `${plugin.id}.${action.id}`.
* - Core actions retain their base IDs (e.g., wait, wizard_speak) for clarity.
*/ */
export class ActionRegistry { export class ActionRegistry {
private static instance: ActionRegistry; private static instance: ActionRegistry;
@@ -31,6 +30,8 @@ export class ActionRegistry {
private loadedStudyId: string | null = null; private loadedStudyId: string | null = null;
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
private readonly SYSTEM_PLUGIN_IDS = ["hristudio-core", "hristudio-woz"];
static getInstance(): ActionRegistry { static getInstance(): ActionRegistry {
if (!ActionRegistry.instance) { if (!ActionRegistry.instance) {
ActionRegistry.instance = new ActionRegistry(); ActionRegistry.instance = new ActionRegistry();
@@ -49,281 +50,26 @@ export class ActionRegistry {
this.listeners.forEach((listener) => listener()); this.listeners.forEach((listener) => listener());
} }
/* ---------------- Core Actions ---------------- */ /* ---------------- Core / System Actions ---------------- */
async loadCoreActions(): Promise<void> { async loadCoreActions(): Promise<void> {
if (this.coreActionsLoaded) return; if (this.coreActionsLoaded) return;
interface CoreBlockParam { // Load System Plugins (Core & WoZ)
id: string; this.registerPluginDefinition(corePluginDef);
name: string; this.registerPluginDefinition(wozPluginDef);
type: string;
placeholder?: string;
options?: string[];
min?: number;
max?: number;
value?: string | number | boolean;
required?: boolean;
description?: string;
step?: number;
}
interface CoreBlock { console.log(
id: string; `[ActionRegistry] Loaded system plugins: ${this.SYSTEM_PLUGIN_IDS.join(", ")}`,
name: string; );
description?: string;
category: string;
icon?: string;
color?: string;
parameters?: CoreBlockParam[];
timeoutMs?: number;
retryable?: boolean;
nestable?: boolean;
}
try { this.coreActionsLoaded = true;
const coreActionSets = [
"wizard-actions",
"control-flow",
"observation",
"events",
];
for (const actionSetId of coreActionSets) {
try {
const response = await fetch(
`/hristudio-core/plugins/${actionSetId}.json`,
);
// Non-blocking skip if not found
if (!response.ok) continue;
const rawActionSet = (await response.json()) as unknown;
const actionSet = rawActionSet as { blocks?: CoreBlock[] };
if (!actionSet.blocks || !Array.isArray(actionSet.blocks)) continue;
// Register each block as an ActionDefinition
actionSet.blocks.forEach((block) => {
if (!block.id || !block.name) return;
const actionDef: ActionDefinition = {
id: block.id,
type: block.id,
name: block.name,
description: block.description ?? "",
category: this.mapBlockCategoryToActionCategory(block.category),
icon: block.icon ?? "Zap",
color: block.color ?? "#6b7280",
parameters: (block.parameters ?? []).map((param) => ({
id: param.id,
name: param.name,
type:
(param.type as "text" | "number" | "select" | "boolean") ||
"text",
placeholder: param.placeholder,
options: param.options,
min: param.min,
max: param.max,
value: param.value,
required: param.required !== false,
description: param.description,
step: param.step,
})),
source: {
kind: "core",
baseActionId: block.id,
},
execution: {
transport: "internal",
timeoutMs: block.timeoutMs,
retryable: block.retryable,
},
parameterSchemaRaw: {
parameters: block.parameters ?? [],
},
nestable: block.nestable,
};
this.actions.set(actionDef.id, actionDef);
});
} catch (error) {
// Non-fatal: we will fallback later
console.warn(`Failed to load core action set ${actionSetId}:`, error);
}
}
this.coreActionsLoaded = true;
this.notifyListeners();
} catch (error) {
console.error("Failed to load core actions:", error);
this.loadFallbackActions();
}
}
private mapBlockCategoryToActionCategory(
category: string,
): ActionDefinition["category"] {
switch (category) {
case "wizard":
return "wizard";
case "event":
return "wizard"; // Events are wizard-initiated triggers
case "robot":
return "robot";
case "control":
return "control";
case "sensor":
case "observation":
return "observation";
default:
return "wizard";
}
}
private loadFallbackActions(): void {
const fallbackActions: ActionDefinition[] = [
{
id: "wizard_say",
type: "wizard_say",
name: "Wizard Says",
description: "Wizard speaks to participant",
category: "wizard",
icon: "MessageSquare",
color: "#a855f7",
parameters: [
{
id: "message",
name: "Message",
type: "text",
placeholder: "Hello, participant!",
required: true,
},
{
id: "tone",
name: "Tone",
type: "select",
options: ["neutral", "friendly", "encouraging"],
value: "neutral",
},
],
source: { kind: "core", baseActionId: "wizard_say" },
execution: { transport: "internal", timeoutMs: 30000 },
parameterSchemaRaw: {},
nestable: false,
},
{
id: "wait",
type: "wait",
name: "Wait",
description: "Wait for specified time",
category: "control",
icon: "Clock",
color: "#f59e0b",
parameters: [
{
id: "duration",
name: "Duration (seconds)",
type: "number",
min: 0.1,
max: 300,
value: 2,
required: true,
},
],
source: { kind: "core", baseActionId: "wait" },
execution: { transport: "internal", timeoutMs: 60000 },
parameterSchemaRaw: {
type: "object",
properties: {
duration: {
type: "number",
minimum: 0.1,
maximum: 300,
default: 2,
},
},
required: ["duration"],
},
},
{
id: "observe",
type: "observe",
name: "Observe",
description: "Record participant behavior",
category: "observation",
icon: "Eye",
color: "#8b5cf6",
parameters: [
{
id: "behavior",
name: "Behavior to observe",
type: "select",
options: ["facial_expression", "body_language", "verbal_response"],
required: true,
},
],
source: { kind: "core", baseActionId: "observe" },
execution: { transport: "internal", timeoutMs: 120000 },
parameterSchemaRaw: {
type: "object",
properties: {
behavior: {
type: "string",
enum: ["facial_expression", "body_language", "verbal_response"],
},
},
required: ["behavior"],
},
},
];
fallbackActions.forEach((action) => this.actions.set(action.id, action));
this.notifyListeners(); this.notifyListeners();
} }
/* ---------------- Plugin Actions ---------------- */ /* ---------------- Plugin Actions ---------------- */
loadPluginActions( loadPluginActions(studyId: string, studyPlugins: any[]): void {
studyId: string,
studyPlugins: Array<{
plugin: {
id: string;
robotId: string | null;
version: string | null;
actionDefinitions?: Array<{
id: string;
name: string;
description?: string;
category?: string;
icon?: string;
timeout?: number;
retryable?: boolean;
aliases?: string[];
parameterSchema?: unknown;
ros2?: {
topic?: string;
messageType?: string;
service?: string;
action?: string;
payloadMapping?: unknown;
qos?: {
reliability?: string;
durability?: string;
history?: string;
depth?: number;
};
};
rest?: {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
path: string;
headers?: Record<string, string>;
};
}>;
metadata?: Record<string, any>;
};
}>,
): void {
// console.log("ActionRegistry.loadPluginActions called with:", { studyId, pluginCount: studyPlugins?.length ?? 0 });
if (this.pluginActionsLoaded && this.loadedStudyId === studyId) return; if (this.pluginActionsLoaded && this.loadedStudyId === studyId) return;
if (this.loadedStudyId !== studyId) { if (this.loadedStudyId !== studyId) {
@@ -332,31 +78,51 @@ export class ActionRegistry {
let totalActionsLoaded = 0; let totalActionsLoaded = 0;
(studyPlugins ?? []).forEach((studyPlugin) => { (studyPlugins ?? []).forEach((plugin) => {
const { plugin } = studyPlugin; this.registerPluginDefinition(plugin);
const actionDefs = Array.isArray(plugin.actionDefinitions) totalActionsLoaded += plugin.actionDefinitions?.length || 0;
? plugin.actionDefinitions });
: undefined;
// console.log(`Plugin ${plugin.id}:`, { actionCount: actionDefs?.length ?? 0 }); console.log(
`ActionRegistry: Loaded ${totalActionsLoaded} plugin actions for study ${studyId}`,
);
if (!actionDefs) return; this.pluginActionsLoaded = true;
this.loadedStudyId = studyId;
this.notifyListeners();
}
actionDefs.forEach((action) => { /* ---------------- Shared Registration Logic ---------------- */
const rawCategory =
typeof action.category === "string"
? action.category.toLowerCase().trim()
: "";
const categoryMap: Record<string, ActionDefinition["category"]> = {
wizard: "wizard",
robot: "robot",
control: "control",
observation: "observation",
};
const category = categoryMap[rawCategory] ?? "robot";
const execution = action.ros2 private registerPluginDefinition(plugin: any) {
? { const actionDefs = Array.isArray(plugin.actionDefinitions)
? plugin.actionDefinitions
: undefined;
if (!actionDefs) return;
actionDefs.forEach((action: any) => {
const rawCategory =
typeof action.category === "string"
? action.category.toLowerCase().trim()
: "";
const categoryMap: Record<string, ActionDefinition["category"]> = {
wizard: "wizard",
robot: "robot",
control: "control",
observation: "observation",
};
// Default category based on plugin type or explicit category
let category = categoryMap[rawCategory];
if (!category) {
if (plugin.id === "hristudio-woz") category = "wizard";
else if (plugin.id === "hristudio-core") category = "control";
else category = "robot";
}
const execution = action.ros2
? {
transport: "ros2" as const, transport: "ros2" as const,
timeoutMs: action.timeout, timeoutMs: action.timeout,
retryable: action.retryable, retryable: action.retryable,
@@ -369,8 +135,8 @@ export class ActionRegistry {
payloadMapping: action.ros2.payloadMapping, payloadMapping: action.ros2.payloadMapping,
}, },
} }
: action.rest : action.rest
? { ? {
transport: "rest" as const, transport: "rest" as const,
timeoutMs: action.timeout, timeoutMs: action.timeout,
retryable: action.retryable, retryable: action.retryable,
@@ -380,62 +146,66 @@ export class ActionRegistry {
headers: action.rest.headers, headers: action.rest.headers,
}, },
} }
: { : {
transport: "internal" as const, transport: "internal" as const,
timeoutMs: action.timeout, timeoutMs: action.timeout,
retryable: action.retryable, retryable: action.retryable,
}; };
// Extract semantic ID from metadata if available, otherwise fall back to database IDs (which typically causes mismatch if seed uses semantic) // Extract semantic ID from metadata if available, otherwise fall back to database IDs
// Ideally, plugin.metadata.robotId should populate this. // Priority: metadata.robotId > metadata.id (for system plugins) > robotId > id
const semanticRobotId = plugin.metadata?.robotId || plugin.robotId || plugin.id; const semanticRobotId =
plugin.metadata?.robotId ||
plugin.metadata?.id ||
plugin.robotId ||
plugin.id;
const actionDef: ActionDefinition = { // For system plugins, we want to keep the short IDs (wait, branch) to avoid breaking existing save data
id: `${semanticRobotId}.${action.id}`, // For robot plugins, we namespace them (nao6-ros2.say_text)
type: `${semanticRobotId}.${action.id}`, const isSystem = this.SYSTEM_PLUGIN_IDS.includes(semanticRobotId);
name: action.name, const actionId = isSystem ? action.id : `${semanticRobotId}.${action.id}`;
description: action.description ?? "", const actionType = actionId; // Type is usually same as ID
category,
icon: action.icon ?? "Bot", const actionDef: ActionDefinition = {
color: "#10b981", id: actionId,
parameters: this.convertParameterSchemaToParameters( type: actionType,
action.parameterSchema, name: action.name,
), description: action.description ?? "",
source: { category,
kind: "plugin", icon: action.icon ?? "Bot",
pluginId: semanticRobotId, // Use semantic ID here too color: action.color || "#10b981",
robotId: plugin.robotId, parameters: this.convertParameterSchemaToParameters(
pluginVersion: plugin.version ?? undefined, action.parameterSchema,
baseActionId: action.id, ),
}, source: {
execution, kind: isSystem ? "core" : "plugin", // Maintain 'core' distinction for UI grouping if needed
parameterSchemaRaw: action.parameterSchema ?? undefined, pluginId: semanticRobotId,
}; robotId: plugin.robotId,
this.actions.set(actionDef.id, actionDef); pluginVersion: plugin.version ?? undefined,
// Register aliases if provided by plugin metadata baseActionId: action.id,
const aliases = Array.isArray(action.aliases) },
? action.aliases execution,
: undefined; parameterSchemaRaw: action.parameterSchema ?? undefined,
if (aliases) { nestable: action.nestable,
for (const alias of aliases) { };
if (typeof alias === "string" && alias.trim()) {
this.aliasIndex.set(alias, actionDef.id); // Prevent overwriting if it already exists (first-come-first-served, usually core first)
} if (!this.actions.has(actionId)) {
this.actions.set(actionId, actionDef);
}
// Register aliases
const aliases = Array.isArray(action.aliases)
? action.aliases
: undefined;
if (aliases) {
for (const alias of aliases) {
if (typeof alias === "string" && alias.trim()) {
this.aliasIndex.set(alias, actionDef.id);
} }
} }
totalActionsLoaded++; }
});
}); });
console.log(
`ActionRegistry: Loaded ${totalActionsLoaded} plugin actions for study ${studyId}`,
);
// console.log("Current action registry state:", { totalActions: this.actions.size });
this.pluginActionsLoaded = true;
this.loadedStudyId = studyId;
this.notifyListeners();
} }
private convertParameterSchemaToParameters( private convertParameterSchemaToParameters(
@@ -458,7 +228,8 @@ export class ActionRegistry {
if (!schema?.properties) return []; if (!schema?.properties) return [];
return Object.entries(schema.properties).map(([key, paramDef]) => { return Object.entries(schema.properties).map(([key, paramDef]) => {
let type: "text" | "number" | "select" | "boolean" = "text"; let type: "text" | "number" | "select" | "boolean" | "json" | "array" =
"text";
if (paramDef.type === "number") { if (paramDef.type === "number") {
type = "number"; type = "number";
@@ -466,6 +237,10 @@ export class ActionRegistry {
type = "boolean"; type = "boolean";
} else if (paramDef.enum && Array.isArray(paramDef.enum)) { } else if (paramDef.enum && Array.isArray(paramDef.enum)) {
type = "select"; type = "select";
} else if (paramDef.type === "array") {
type = "array";
} else if (paramDef.type === "object") {
type = "json";
} }
return { return {
@@ -485,29 +260,20 @@ export class ActionRegistry {
private resetPluginActions(): void { private resetPluginActions(): void {
this.pluginActionsLoaded = false; this.pluginActionsLoaded = false;
this.loadedStudyId = null; this.loadedStudyId = null;
// Remove existing plugin actions (retain known core ids + fallback ids)
const pluginActionIds = Array.from(this.actions.keys()).filter( // Robust Reset: Remove valid plugin actions, BUT protect system plugins.
(id) => const idsToDelete: string[] = [];
!id.startsWith("wizard_") && this.actions.forEach((action, id) => {
!id.startsWith("when_") && if (
!id.startsWith("wait") && action.source.kind === "plugin" &&
!id.startsWith("observe") && !this.SYSTEM_PLUGIN_IDS.includes(action.source.pluginId || "")
!id.startsWith("repeat") && ) {
!id.startsWith("if_") && idsToDelete.push(id);
!id.startsWith("parallel") && }
!id.startsWith("sequence") && });
!id.startsWith("random_") &&
!id.startsWith("try_") && idsToDelete.forEach((id) => this.actions.delete(id));
!id.startsWith("break") && this.notifyListeners();
!id.startsWith("measure_") &&
!id.startsWith("count_") &&
!id.startsWith("record_") &&
!id.startsWith("capture_") &&
!id.startsWith("log_") &&
!id.startsWith("survey_") &&
!id.startsWith("physiological_"),
);
pluginActionIds.forEach((id) => this.actions.delete(id));
} }
/* ---------------- Query Helpers ---------------- */ /* ---------------- Query Helpers ---------------- */

View File

@@ -8,11 +8,23 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Play, RefreshCw, HelpCircle } from "lucide-react"; import {
Play,
RefreshCw,
HelpCircle,
PanelLeftClose,
PanelLeftOpen,
PanelRightClose,
PanelRightOpen,
Maximize2,
Minimize2,
Settings,
} from "lucide-react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { PageHeader } from "~/components/ui/page-header"; import { PageHeader } from "~/components/ui/page-header";
import { useTour } from "~/components/onboarding/TourProvider"; import { useTour } from "~/components/onboarding/TourProvider";
import { SettingsModal } from "./SettingsModal";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
@@ -27,7 +39,7 @@ import {
MouseSensor, MouseSensor,
TouchSensor, TouchSensor,
KeyboardSensor, KeyboardSensor,
closestCorners, closestCenter,
type DragEndEvent, type DragEndEvent,
type DragStartEvent, type DragStartEvent,
type DragOverEvent, type DragOverEvent,
@@ -35,7 +47,9 @@ import {
import { BottomStatusBar } from "./layout/BottomStatusBar"; import { BottomStatusBar } from "./layout/BottomStatusBar";
import { ActionLibraryPanel } from "./panels/ActionLibraryPanel"; import { ActionLibraryPanel } from "./panels/ActionLibraryPanel";
import { InspectorPanel } from "./panels/InspectorPanel"; import { InspectorPanel } from "./panels/InspectorPanel";
import { FlowWorkspace } from "./flow/FlowWorkspace"; import { FlowWorkspace, StepCardPreview } from "./flow/FlowWorkspace";
import { SortableActionChip } from "./flow/ActionChip";
import { GripVertical } from "lucide-react";
import { import {
type ExperimentDesign, type ExperimentDesign,
@@ -44,12 +58,13 @@ import {
} from "~/lib/experiment-designer/types"; } from "~/lib/experiment-designer/types";
import { useDesignerStore } from "./state/store"; import { useDesignerStore } from "./state/store";
import { actionRegistry } from "./ActionRegistry"; import { actionRegistry, useActionRegistry } from "./ActionRegistry";
import { computeDesignHash } from "./state/hashing"; import { computeDesignHash } from "./state/hashing";
import { import {
validateExperimentDesign, validateExperimentDesign,
groupIssuesByEntity, groupIssuesByEntity,
} from "./state/validators"; } from "./state/validators";
import { convertDatabaseToSteps } from "~/lib/experiment-designer/block-converter";
/** /**
* DesignerRoot * DesignerRoot
@@ -84,6 +99,23 @@ export interface DesignerRootProps {
initialDesign?: ExperimentDesign; initialDesign?: ExperimentDesign;
autoCompile?: boolean; autoCompile?: boolean;
onPersist?: (design: ExperimentDesign) => void; onPersist?: (design: ExperimentDesign) => void;
experiment?: {
id: string;
name: string;
description: string | null;
status: string;
studyId: string;
createdAt: Date;
updatedAt: Date;
study: {
id: string;
name: string;
};
};
designStats?: {
stepCount: number;
actionCount: number;
};
} }
interface RawExperiment { interface RawExperiment {
@@ -94,6 +126,7 @@ interface RawExperiment {
integrityHash?: string | null; integrityHash?: string | null;
pluginDependencies?: string[] | null; pluginDependencies?: string[] | null;
visualDesign?: unknown; visualDesign?: unknown;
steps?: unknown[]; // DB steps from relation
} }
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@@ -101,6 +134,65 @@ interface RawExperiment {
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined { function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
console.log("[adaptExistingDesign] Entry - exp.steps:", exp.steps);
// 1. Prefer database steps (Source of Truth) if valid, to ensure we have the latest
// plugin provenance data (which might be missing from stale visualDesign snapshots).
// 1. Prefer database steps (Source of Truth) if valid.
if (Array.isArray(exp.steps) && exp.steps.length > 0) {
console.log(
"[adaptExistingDesign] Has steps array, length:",
exp.steps.length,
);
try {
// Check if steps are already converted (have trigger property) to avoid double-conversion data loss
const firstStep = exp.steps[0] as any;
let dbSteps: ExperimentStep[];
if (
firstStep &&
typeof firstStep === "object" &&
"trigger" in firstStep
) {
// Already converted by server
dbSteps = exp.steps as ExperimentStep[];
} else {
// Raw DB steps, need conversion
console.log("[adaptExistingDesign] Taking raw DB conversion path");
dbSteps = convertDatabaseToSteps(exp.steps);
// DEBUG: Check children after conversion
dbSteps.forEach((step) => {
step.actions.forEach((action) => {
if (
["sequence", "parallel", "loop", "branch"].includes(action.type)
) {
console.log(
`[adaptExistingDesign] Post-conversion ${action.type} (${action.name}) children:`,
action.children,
);
}
});
});
}
return {
id: exp.id,
name: exp.name,
description: exp.description ?? "",
steps: dbSteps,
version: 1, // Reset version on re-hydration
lastSaved: new Date(),
};
} catch (err) {
console.warn(
"[DesignerRoot] Failed to convert/hydrate steps, falling back to visualDesign:",
err,
);
}
}
// 2. Fallback to visualDesign blob if DB steps unavailable or conversion failed
if ( if (
!exp.visualDesign || !exp.visualDesign ||
typeof exp.visualDesign !== "object" || typeof exp.visualDesign !== "object" ||
@@ -114,6 +206,7 @@ function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
lastSaved?: string; lastSaved?: string;
}; };
if (!Array.isArray(vd.steps)) return undefined; if (!Array.isArray(vd.steps)) return undefined;
return { return {
id: exp.id, id: exp.id,
name: exp.name, name: exp.name,
@@ -151,7 +244,12 @@ export function DesignerRoot({
initialDesign, initialDesign,
autoCompile = true, autoCompile = true,
onPersist, onPersist,
experiment: experimentMetadata,
designStats,
}: DesignerRootProps) { }: DesignerRootProps) {
// Subscribe to registry updates to ensure re-renders when actions load
useActionRegistry();
const { startTour } = useTour(); const { startTour } = useTour();
/* ----------------------------- Remote Experiment ------------------------- */ /* ----------------------------- Remote Experiment ------------------------- */
@@ -159,7 +257,16 @@ export function DesignerRoot({
data: experiment, data: experiment,
isLoading: loadingExperiment, isLoading: loadingExperiment,
refetch: refetchExperiment, refetch: refetchExperiment,
} = api.experiments.get.useQuery({ id: experimentId }); } = api.experiments.get.useQuery(
{ id: experimentId },
{
// Debug Mode: Disable all caching to ensure fresh data from DB
refetchOnMount: true,
refetchOnWindowFocus: true,
staleTime: 0,
gcTime: 0, // Garbage collect immediately
},
);
const updateExperiment = api.experiments.update.useMutation({ const updateExperiment = api.experiments.update.useMutation({
onError: (err) => { onError: (err) => {
@@ -199,6 +306,7 @@ export function DesignerRoot({
const upsertAction = useDesignerStore((s) => s.upsertAction); const upsertAction = useDesignerStore((s) => s.upsertAction);
const selectStep = useDesignerStore((s) => s.selectStep); const selectStep = useDesignerStore((s) => s.selectStep);
const selectAction = useDesignerStore((s) => s.selectAction); const selectAction = useDesignerStore((s) => s.selectAction);
const reorderStep = useDesignerStore((s) => s.reorderStep);
const setValidationIssues = useDesignerStore((s) => s.setValidationIssues); const setValidationIssues = useDesignerStore((s) => s.setValidationIssues);
const clearAllValidationIssues = useDesignerStore( const clearAllValidationIssues = useDesignerStore(
(s) => s.clearAllValidationIssues, (s) => s.clearAllValidationIssues,
@@ -258,6 +366,24 @@ export function DesignerRoot({
const [inspectorTab, setInspectorTab] = useState< const [inspectorTab, setInspectorTab] = useState<
"properties" | "issues" | "dependencies" "properties" | "issues" | "dependencies"
>("properties"); >("properties");
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
// Responsive initialization: Collapse left sidebar on smaller screens (<1280px)
useEffect(() => {
const checkWidth = () => {
if (window.innerWidth < 1280) {
setLeftCollapsed(true);
}
};
// Check once on mount
checkWidth();
// Optional: Add resize listener if we want live responsiveness
// window.addEventListener('resize', checkWidth);
// return () => window.removeEventListener('resize', checkWidth);
}, []);
/** /**
* Active action being dragged from the Action Library (for DragOverlay rendering). * Active action being dragged from the Action Library (for DragOverlay rendering).
* Captures a lightweight subset for visual feedback. * Captures a lightweight subset for visual feedback.
@@ -269,16 +395,24 @@ export function DesignerRoot({
description?: string; description?: string;
} | null>(null); } | null>(null);
const [activeSortableItem, setActiveSortableItem] = useState<{
type: "step" | "action";
data: any;
} | null>(null);
/* ----------------------------- Initialization ---------------------------- */ /* ----------------------------- Initialization ---------------------------- */
useEffect(() => { useEffect(() => {
console.log("[DesignerRoot] useEffect triggered", {
initialized,
loadingExperiment,
hasExperiment: !!experiment,
hasInitialDesign: !!initialDesign,
});
if (initialized) return; if (initialized) return;
if (loadingExperiment && !initialDesign) return; if (loadingExperiment && !initialDesign) return;
// console.log('[DesignerRoot] 🚀 INITIALIZING', { console.log("[DesignerRoot] Proceeding with initialization");
// hasExperiment: !!experiment,
// hasInitialDesign: !!initialDesign,
// loadingExperiment,
// });
const adapted = const adapted =
initialDesign ?? initialDesign ??
@@ -327,13 +461,14 @@ export function DesignerRoot({
.catch((err) => console.error("Core action load failed:", err)); .catch((err) => console.error("Core action load failed:", err));
}, []); }, []);
// Load plugin actions when study plugins available // Load plugin actions only after we have the flattened, processed plugin list
useEffect(() => { useEffect(() => {
if (!experiment?.studyId) return; if (!experiment?.studyId) return;
if (!studyPluginsRaw) return; if (!studyPlugins) return;
// @ts-expect-error - studyPluginsRaw type from tRPC is compatible but TypeScript can't infer it
actionRegistry.loadPluginActions(experiment.studyId, studyPluginsRaw); // Pass the flattened plugins which match the structure ActionRegistry expects
}, [experiment?.studyId, studyPluginsRaw]); actionRegistry.loadPluginActions(experiment.studyId, studyPlugins);
}, [experiment?.studyId, studyPlugins]);
/* ------------------------- Ready State Management ------------------------ */ /* ------------------------- Ready State Management ------------------------ */
// Mark as ready once initialized and plugins are loaded // Mark as ready once initialized and plugins are loaded
@@ -348,11 +483,10 @@ export function DesignerRoot({
// Small delay to ensure all components have rendered // Small delay to ensure all components have rendered
const timer = setTimeout(() => { const timer = setTimeout(() => {
setIsReady(true); setIsReady(true);
// console.log('[DesignerRoot] ✅ Designer ready (plugins loaded), fading in');
}, 150); }, 150);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [initialized, isReady, studyPluginsRaw]); }, [initialized, isReady, studyPlugins]);
/* ----------------------- Automatic Hash Recomputation -------------------- */ /* ----------------------- Automatic Hash Recomputation -------------------- */
// Automatically recompute hash when steps change (debounced to avoid excessive computation) // Automatically recompute hash when steps change (debounced to avoid excessive computation)
@@ -372,7 +506,6 @@ export function DesignerRoot({
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [steps, initialized, recomputeHash]); }, [steps, initialized, recomputeHash]);
/* ----------------------------- Derived State ----------------------------- */ /* ----------------------------- Derived State ----------------------------- */
const hasUnsavedChanges = const hasUnsavedChanges =
!!currentDesignHash && lastPersistedHash !== currentDesignHash; !!currentDesignHash && lastPersistedHash !== currentDesignHash;
@@ -415,6 +548,7 @@ export function DesignerRoot({
const currentSteps = [...steps]; const currentSteps = [...steps];
// Ensure core actions are loaded before validating // Ensure core actions are loaded before validating
await actionRegistry.loadCoreActions(); await actionRegistry.loadCoreActions();
const result = validateExperimentDesign(currentSteps, { const result = validateExperimentDesign(currentSteps, {
steps: currentSteps, steps: currentSteps,
actionDefinitions: actionRegistry.getAllActions(), actionDefinitions: actionRegistry.getAllActions(),
@@ -424,20 +558,30 @@ export function DesignerRoot({
// Debug: Improved structured logging for validation results // Debug: Improved structured logging for validation results
console.group("🧪 Experiment Validation Results"); console.group("🧪 Experiment Validation Results");
if (result.valid) { if (result.valid) {
console.log(`%c✓ VALID (0 errors, ${result.warningCount} warnings, ${result.infoCount} hints)`, "color: green; font-weight: bold; font-size: 12px;"); console.log(
`%c✓ VALID (0 errors, ${result.warningCount} warnings, ${result.infoCount} hints)`,
"color: green; font-weight: bold; font-size: 12px;",
);
} else { } else {
console.log(`%c✗ INVALID (${result.errorCount} errors, ${result.warningCount} warnings)`, "color: red; font-weight: bold; font-size: 12px;"); console.log(
`%c✗ INVALID (${result.errorCount} errors, ${result.warningCount} warnings)`,
"color: red; font-weight: bold; font-size: 12px;",
);
} }
if (result.issues.length > 0) { if (result.issues.length > 0) {
console.table( console.table(
result.issues.map(i => ({ result.issues.map((i) => ({
Severity: i.severity.toUpperCase(), Severity: i.severity.toUpperCase(),
Category: i.category, Category: i.category,
Message: i.message, Message: i.message,
Suggest: i.suggestion, Suggest: i.suggestion,
Location: i.actionId ? `Action ${i.actionId}` : (i.stepId ? `Step ${i.stepId}` : 'Global') Location: i.actionId
})) ? `Action ${i.actionId}`
: i.stepId
? `Step ${i.stepId}`
: "Global",
})),
); );
} else { } else {
console.log("No issues found. Design is perfectly compliant."); console.log("No issues found. Design is perfectly compliant.");
@@ -468,7 +612,8 @@ export function DesignerRoot({
} }
} catch (err) { } catch (err) {
toast.error( toast.error(
`Validation error: ${err instanceof Error ? err.message : "Unknown error" `Validation error: ${
err instanceof Error ? err.message : "Unknown error"
}`, }`,
); );
} finally { } finally {
@@ -482,11 +627,20 @@ export function DesignerRoot({
clearAllValidationIssues, clearAllValidationIssues,
]); ]);
// Trigger initial validation when ready (plugins loaded) to ensure no stale errors
// Trigger initial validation when ready (plugins loaded) to ensure no stale errors
// DISABLED: User prefers manual validation to avoid noise on improved sequential architecture
// useEffect(() => {
// if (isReady) {
// void validateDesign();
// }
// }, [isReady, validateDesign]);
/* --------------------------------- Save ---------------------------------- */ /* --------------------------------- Save ---------------------------------- */
const persist = useCallback(async () => { const persist = useCallback(async () => {
if (!initialized) return; if (!initialized) return;
console.log('[DesignerRoot] 💾 SAVE initiated', { console.log("[DesignerRoot] 💾 SAVE initiated", {
stepsCount: steps.length, stepsCount: steps.length,
actionsCount: steps.reduce((sum, s) => sum + s.actions.length, 0), actionsCount: steps.reduce((sum, s) => sum + s.actions.length, 0),
currentHash: currentDesignHash?.slice(0, 16), currentHash: currentDesignHash?.slice(0, 16),
@@ -501,7 +655,7 @@ export function DesignerRoot({
lastSaved: new Date().toISOString(), lastSaved: new Date().toISOString(),
}; };
console.log('[DesignerRoot] 💾 Sending to server...', { console.log("[DesignerRoot] 💾 Sending to server...", {
experimentId, experimentId,
stepsCount: steps.length, stepsCount: steps.length,
version: designMeta.version, version: designMeta.version,
@@ -515,7 +669,7 @@ export function DesignerRoot({
compileExecution: autoCompile, compileExecution: autoCompile,
}); });
console.log('[DesignerRoot] 💾 Server save successful'); console.log("[DesignerRoot] 💾 Server save successful");
// NOTE: We do NOT refetch here because it would reset the local steps state // NOTE: We do NOT refetch here because it would reset the local steps state
// to the server state, which would cause the hash to match the persisted hash, // to the server state, which would cause the hash to match the persisted hash,
@@ -525,7 +679,7 @@ export function DesignerRoot({
// Recompute hash and update persisted hash // Recompute hash and update persisted hash
const hashResult = await recomputeHash(); const hashResult = await recomputeHash();
if (hashResult?.designHash) { if (hashResult?.designHash) {
console.log('[DesignerRoot] 💾 Updated persisted hash:', { console.log("[DesignerRoot] 💾 Updated persisted hash:", {
newPersistedHash: hashResult.designHash.slice(0, 16), newPersistedHash: hashResult.designHash.slice(0, 16),
fullHash: hashResult.designHash, fullHash: hashResult.designHash,
}); });
@@ -535,7 +689,10 @@ export function DesignerRoot({
setLastSavedAt(new Date()); setLastSavedAt(new Date());
toast.success("Experiment saved"); toast.success("Experiment saved");
console.log('[DesignerRoot] 💾 SAVE complete'); // Auto-validate after save to clear "Modified" (drift) status
void validateDesign();
console.log("[DesignerRoot] 💾 SAVE complete");
onPersist?.({ onPersist?.({
id: experimentId, id: experimentId,
@@ -546,7 +703,7 @@ export function DesignerRoot({
lastSaved: new Date(), lastSaved: new Date(),
}); });
} catch (error) { } catch (error) {
console.error('[DesignerRoot] 💾 SAVE failed:', error); console.error("[DesignerRoot] 💾 SAVE failed:", error);
// Error already handled by mutation onError // Error already handled by mutation onError
} finally { } finally {
setIsSaving(false); setIsSaving(false);
@@ -602,7 +759,8 @@ export function DesignerRoot({
toast.success("Exported design bundle"); toast.success("Exported design bundle");
} catch (err) { } catch (err) {
toast.error( toast.error(
`Export failed: ${err instanceof Error ? err.message : "Unknown error" `Export failed: ${
err instanceof Error ? err.message : "Unknown error"
}`, }`,
); );
} finally { } finally {
@@ -664,15 +822,18 @@ export function DesignerRoot({
useSensor(KeyboardSensor), useSensor(KeyboardSensor),
); );
/* ----------------------------- Drag Handlers ----------------------------- */
/* ----------------------------- Drag Handlers ----------------------------- */ /* ----------------------------- Drag Handlers ----------------------------- */
const handleDragStart = useCallback( const handleDragStart = useCallback(
(event: DragStartEvent) => { (event: DragStartEvent) => {
const { active } = event; const { active } = event;
if ( const activeId = active.id.toString();
active.id.toString().startsWith("action-") && const activeData = active.data.current;
active.data.current?.action
) { console.log("[DesignerRoot] DragStart", { activeId, activeData });
const a = active.data.current.action as {
if (activeId.startsWith("action-") && activeData?.action) {
const a = activeData.action as {
id: string; id: string;
name: string; name: string;
category: string; category: string;
@@ -686,6 +847,21 @@ export function DesignerRoot({
category: a.category, category: a.category,
description: a.description, description: a.description,
}); });
} else if (activeId.startsWith("s-step-")) {
console.log("[DesignerRoot] Setting active sortable STEP", activeData);
setActiveSortableItem({
type: "step",
data: activeData,
});
} else if (activeId.startsWith("s-act-")) {
console.log(
"[DesignerRoot] Setting active sortable ACTION",
activeData,
);
setActiveSortableItem({
type: "action",
data: activeData,
});
} }
}, },
[toggleLibraryScrollLock], [toggleLibraryScrollLock],
@@ -694,16 +870,17 @@ export function DesignerRoot({
const handleDragOver = useCallback((event: DragOverEvent) => { const handleDragOver = useCallback((event: DragOverEvent) => {
const { active, over } = event; const { active, over } = event;
const store = useDesignerStore.getState(); const store = useDesignerStore.getState();
const activeId = active.id.toString();
// Only handle Library -> Flow projection if (!over) {
if (!active.id.toString().startsWith("action-")) {
if (store.insertionProjection) { if (store.insertionProjection) {
store.setInsertionProjection(null); store.setInsertionProjection(null);
} }
return; return;
} }
if (!over) { // 3. Library -> Flow Projection (Action)
if (!activeId.startsWith("action-")) {
if (store.insertionProjection) { if (store.insertionProjection) {
store.setInsertionProjection(null); store.setInsertionProjection(null);
} }
@@ -744,10 +921,10 @@ export function DesignerRoot({
// Let's assume index 0 for now (prepend) or implement lookup. // Let's assume index 0 for now (prepend) or implement lookup.
// Better: lookup action -> children length. // Better: lookup action -> children length.
const actionId = parentId; const actionId = parentId;
const step = store.steps.find(s => s.id === stepId); const step = store.steps.find((s) => s.id === stepId);
// Find action recursive? Store has `findActionById` helper but it is not exported/accessible easily here? // Find action recursive? Store has `findActionById` helper but it is not exported/accessible easily here?
// Actually, `store.steps` is available. // Actually, `store.steps` is available.
// We can implement a quick BFS/DFS or just assume 0. // We can implement a quick BFS/DFS or just assume 0.
// If dragging over the container *background* (empty space), append is usually expected. // If dragging over the container *background* (empty space), append is usually expected.
// Let's try 9999? // Let's try 9999?
index = 9999; index = 9999;
@@ -759,7 +936,6 @@ export function DesignerRoot({
: overId.slice("step-".length); : overId.slice("step-".length);
const step = store.steps.find((s) => s.id === stepId); const step = store.steps.find((s) => s.id === stepId);
index = step ? step.actions.length : 0; index = step ? step.actions.length : 0;
} else if (overId === "projection-placeholder") { } else if (overId === "projection-placeholder") {
// Hovering over our own projection placeholder -> keep current state // Hovering over our own projection placeholder -> keep current state
return; return;
@@ -804,6 +980,7 @@ export function DesignerRoot({
// Clear overlay immediately // Clear overlay immediately
toggleLibraryScrollLock(false); toggleLibraryScrollLock(false);
setDragOverlayAction(null); setDragOverlayAction(null);
setActiveSortableItem(null);
// Capture and clear projection // Capture and clear projection
const store = useDesignerStore.getState(); const store = useDesignerStore.getState();
@@ -814,6 +991,38 @@ export function DesignerRoot({
return; return;
} }
const activeId = active.id.toString();
// Handle Step Reordering (Active is a sortable step)
if (activeId.startsWith("s-step-")) {
const overId = over.id.toString();
// Allow reordering over both sortable steps (s-step-) and drop zones (step-)
if (!overId.startsWith("s-step-") && !overId.startsWith("step-"))
return;
// Strip prefixes to get raw IDs
const rawActiveId = activeId.replace(/^s-step-/, "");
const rawOverId = overId.replace(/^s-step-/, "").replace(/^step-/, "");
console.log("[DesignerRoot] DragEnd - Step Sort", {
activeId,
overId,
rawActiveId,
rawOverId,
});
const oldIndex = steps.findIndex((s) => s.id === rawActiveId);
const newIndex = steps.findIndex((s) => s.id === rawOverId);
console.log("[DesignerRoot] Indices", { oldIndex, newIndex });
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
console.log("[DesignerRoot] Reordering...");
reorderStep(oldIndex, newIndex);
}
return;
}
// 1. Determine Target (Step, Parent, Index) // 1. Determine Target (Step, Parent, Index)
let stepId: string | null = null; let stepId: string | null = null;
let parentId: string | null = null; let parentId: string | null = null;
@@ -845,7 +1054,10 @@ export function DesignerRoot({
if (!targetStep) return; if (!targetStep) return;
// 2. Instantiate Action // 2. Instantiate Action
if (active.id.toString().startsWith("action-") && active.data.current?.action) { if (
active.id.toString().startsWith("action-") &&
active.data.current?.action
) {
const actionDef = active.data.current.action as { const actionDef = active.data.current.action as {
id: string; // type id: string; // type
type: string; type: string;
@@ -861,38 +1073,39 @@ export function DesignerRoot({
const defaultParams: Record<string, unknown> = {}; const defaultParams: Record<string, unknown> = {};
if (fullDef?.parameters) { if (fullDef?.parameters) {
for (const param of fullDef.parameters) { for (const param of fullDef.parameters) {
// @ts-expect-error - 'default' property access if (param.value !== undefined) {
if (param.default !== undefined) { defaultParams[param.id] = param.value;
// @ts-expect-error - 'default' property access
defaultParams[param.id] = param.default;
} }
} }
} }
const execution: ExperimentAction["execution"] = const execution: ExperimentAction["execution"] =
actionDef.execution && actionDef.execution &&
(actionDef.execution.transport === "internal" || (actionDef.execution.transport === "internal" ||
actionDef.execution.transport === "rest" || actionDef.execution.transport === "rest" ||
actionDef.execution.transport === "ros2") actionDef.execution.transport === "ros2")
? { ? {
transport: actionDef.execution.transport, transport: actionDef.execution.transport,
retryable: actionDef.execution.retryable ?? false, retryable: actionDef.execution.retryable ?? false,
} }
: undefined; : undefined;
const newId = `action-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`;
const newAction: ExperimentAction = { const newAction: ExperimentAction = {
id: crypto.randomUUID(), id: newId,
type: actionDef.type, // this is the 'type' key type: actionDef.type, // this is the 'type' key
name: actionDef.name, name: actionDef.name,
category: actionDef.category as any, category: actionDef.category as any,
description: "", description: "",
parameters: defaultParams, parameters: defaultParams,
source: actionDef.source ? { source: actionDef.source
kind: actionDef.source.kind as any, ? {
pluginId: actionDef.source.pluginId, kind: actionDef.source.kind as any,
pluginVersion: actionDef.source.pluginVersion, pluginId: actionDef.source.pluginId,
baseActionId: actionDef.id pluginVersion: actionDef.source.pluginVersion,
} : { kind: "core" }, baseActionId: actionDef.id,
}
: { kind: "core" },
execution, execution,
children: [], children: [],
}; };
@@ -906,13 +1119,25 @@ export function DesignerRoot({
void recomputeHash(); void recomputeHash();
} }
}, },
[steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock], [
steps,
upsertAction,
selectAction,
recomputeHash,
toggleLibraryScrollLock,
reorderStep,
],
); );
// validation status badges removed (unused) // validation status badges removed (unused)
/* ------------------------------- Panels ---------------------------------- */ /* ------------------------------- Panels ---------------------------------- */
const leftPanel = useMemo( const leftPanel = useMemo(
() => ( () => (
<div id="tour-designer-blocks" ref={libraryRootRef} data-library-root className="h-full"> <div
id="tour-designer-blocks"
ref={libraryRootRef}
data-library-root
className="h-full"
>
<ActionLibraryPanel /> <ActionLibraryPanel />
</div> </div>
), ),
@@ -935,10 +1160,11 @@ export function DesignerRoot({
activeTab={inspectorTab} activeTab={inspectorTab}
onTabChange={setInspectorTab} onTabChange={setInspectorTab}
studyPlugins={studyPlugins} studyPlugins={studyPlugins}
onClearAll={clearAllValidationIssues}
/> />
</div> </div>
), ),
[inspectorTab, studyPlugins], [inspectorTab, studyPlugins, clearAllValidationIssues],
); );
/* ------------------------------- Render ---------------------------------- */ /* ------------------------------- Render ---------------------------------- */
@@ -952,6 +1178,16 @@ export function DesignerRoot({
const actions = ( const actions = (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{experimentMetadata && (
<Button
variant="ghost"
size="icon"
onClick={() => setSettingsOpen(true)}
title="Experiment Settings"
>
<Settings className="h-5 w-5" />
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -982,83 +1218,198 @@ export function DesignerRoot({
); );
return ( return (
<div className="flex h-full w-full flex-col overflow-hidden"> <div className="bg-background relative flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden">
{/* Subtle Background Gradients */}
<div className="bg-primary/10 absolute top-0 left-1/2 -z-10 h-[400px] w-[800px] -translate-x-1/2 rounded-full opacity-20 blur-3xl dark:opacity-10" />
<div className="absolute right-0 bottom-0 -z-10 h-[250px] w-[250px] rounded-full bg-violet-500/5 blur-3xl" />
<PageHeader <PageHeader
title={designMeta.name} title={designMeta.name}
description={designMeta.description || "No description"} description={designMeta.description || "No description"}
icon={Play} icon={Play}
actions={actions} actions={actions}
className="pb-6" className="flex-none pb-4"
/> />
<div className="relative flex flex-1 flex-col overflow-hidden"> {/* Main Grid Container - 2-4-2 Split */}
{/* Loading Overlay */} {/* Main Grid Container - 2-4-2 Split */}
{!isReady && ( <div className="min-h-0 w-full flex-1 overflow-hidden px-2">
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background"> <DndContext
<div className="flex flex-col items-center gap-4"> sensors={sensors}
<RefreshCw className="h-8 w-8 animate-spin text-primary" /> collisionDetection={closestCenter}
<p className="text-muted-foreground text-sm">Loading designer...</p> onDragStart={handleDragStart}
</div> onDragOver={handleDragOver}
</div> onDragEnd={handleDragEnd}
)} onDragCancel={() => toggleLibraryScrollLock(false)}
{/* Main Content - Fade in when ready */}
<div
className={cn(
"flex flex-1 flex-col overflow-hidden transition-opacity duration-500",
isReady ? "opacity-100" : "opacity-0"
)}
> >
<div className="flex h-[calc(100vh-12rem)] w-full max-w-full flex-col overflow-hidden rounded-md border"> <div className="grid h-full w-full grid-cols-8 gap-4 transition-all duration-300 ease-in-out">
<DndContext {/* Left Panel (Library) */}
sensors={sensors} {!leftCollapsed && (
collisionDetection={closestCorners} <div
onDragStart={handleDragStart} className={cn(
onDragOver={handleDragOver} "bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm",
onDragEnd={handleDragEnd} rightCollapsed ? "col-span-3" : "col-span-2",
onDragCancel={() => toggleLibraryScrollLock(false)} )}
>
<div className="bg-muted/30 flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-medium">Action Library</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setLeftCollapsed(true)}
>
<PanelLeftClose className="h-4 w-4" />
</Button>
</div>
<div className="bg-muted/10 min-h-0 flex-1 overflow-hidden">
{leftPanel}
</div>
</div>
)}
{/* Center Panel (Workspace) */}
<div
className={cn(
"bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm",
leftCollapsed && rightCollapsed
? "col-span-8"
: leftCollapsed
? "col-span-6"
: rightCollapsed
? "col-span-5"
: "col-span-4",
)}
> >
<PanelsContainer <div className="bg-muted/30 flex items-center justify-between border-b px-3 py-2">
showDividers {leftCollapsed && (
className="min-h-0 flex-1" <Button
left={leftPanel} variant="ghost"
center={centerPanel} size="icon"
right={rightPanel} className="mr-2 h-6 w-6"
/> onClick={() => setLeftCollapsed(false)}
<DragOverlay> title="Open Library"
{dragOverlayAction ? ( >
<div className="bg-background flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg select-none"> <PanelLeftOpen className="h-4 w-4" />
<span </Button>
className={cn( )}
"h-2.5 w-2.5 rounded-full", <span className="text-sm font-medium">Flow Workspace</span>
{ {rightCollapsed && (
wizard: "bg-blue-500", <div className="flex items-center">
robot: "bg-emerald-600", <Button
control: "bg-amber-500", variant="ghost"
observation: "bg-purple-600", size="icon"
}[dragOverlayAction.category] || "bg-slate-400", className="h-7 w-7"
)} onClick={() => startTour("designer")}
/> >
{dragOverlayAction.name} <HelpCircle className="h-4 w-4" />
</Button>
{rightCollapsed && (
<Button
variant="ghost"
size="icon"
className="ml-2 h-6 w-6"
onClick={() => setRightCollapsed(false)}
title="Open Inspector"
>
<PanelRightOpen className="h-4 w-4" />
</Button>
)}
</div> </div>
) : null} )}
</DragOverlay> </div>
</DndContext> <div className="relative min-h-0 flex-1 overflow-hidden">
<div className="flex-shrink-0 border-t"> {centerPanel}
<BottomStatusBar </div>
onSave={() => persist()} <div className="border-t">
onValidate={() => validateDesign()} <BottomStatusBar
onExport={() => handleExport()} onSave={() => persist()}
onRecalculateHash={() => recomputeHash()} onValidate={() => validateDesign()}
lastSavedAt={lastSavedAt} onExport={() => handleExport()}
saving={isSaving} onRecalculateHash={() => recomputeHash()}
validating={isValidating} lastSavedAt={lastSavedAt}
exporting={isExporting} saving={isSaving}
/> validating={isValidating}
exporting={isExporting}
/>
</div>
</div> </div>
{/* Right Panel (Inspector) */}
{!rightCollapsed && (
<div
className={cn(
"bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm",
leftCollapsed ? "col-span-2" : "col-span-2",
)}
>
<div className="bg-muted/30 flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-medium">Inspector</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setRightCollapsed(true)}
>
<PanelRightClose className="h-4 w-4" />
</Button>
</div>
<div className="bg-muted/10 min-h-0 flex-1 overflow-hidden">
{rightPanel}
</div>
</div>
)}
</div> </div>
</div>
<DragOverlay dropAnimation={null}>
{dragOverlayAction ? (
// Library Item Drag
<div className="bg-background pointer-events-none flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg ring-2 ring-blue-500/20 select-none">
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded text-white",
dragOverlayAction.category === "robot" && "bg-emerald-600",
dragOverlayAction.category === "control" && "bg-amber-500",
dragOverlayAction.category === "observation" &&
"bg-purple-600",
)}
/>
{dragOverlayAction.name}
</div>
) : activeSortableItem?.type === "action" ? (
// Existing Action Sort
<div className="pointer-events-none w-[300px] opacity-90">
<SortableActionChip
stepId={activeSortableItem.data.stepId}
action={activeSortableItem.data.action}
parentId={activeSortableItem.data.parentId}
selectedActionId={selectedActionId}
onSelectAction={() => {}}
onDeleteAction={() => {}}
dragHandle={true}
/>
</div>
) : activeSortableItem?.type === "step" ? (
// Existing Step Sort
<div className="pointer-events-none w-[400px] opacity-90">
<StepCardPreview
step={activeSortableItem.data.step}
dragHandle
/>
</div>
) : null}
</DragOverlay>
</DndContext>
</div> </div>
{/* Settings Modal */}
{experimentMetadata && (
<SettingsModal
open={settingsOpen}
onOpenChange={setSettingsOpen}
experiment={experimentMetadata}
designStats={designStats}
/>
)}
</div> </div>
); );
} }

View File

@@ -23,6 +23,7 @@ import {
type ExperimentDesign, type ExperimentDesign,
} from "~/lib/experiment-designer/types"; } from "~/lib/experiment-designer/types";
import { actionRegistry } from "./ActionRegistry"; import { actionRegistry } from "./ActionRegistry";
import { Button } from "~/components/ui/button";
import { import {
Settings, Settings,
Zap, Zap,
@@ -39,6 +40,9 @@ import {
Mic, Mic,
Activity, Activity,
Play, Play,
Plus,
GitBranch,
Trash2,
} from "lucide-react"; } from "lucide-react";
/** /**
@@ -166,7 +170,30 @@ export function PropertiesPanelBase({
/* -------------------------- Action Properties View -------------------------- */ /* -------------------------- Action Properties View -------------------------- */
if (selectedAction && containingStep) { if (selectedAction && containingStep) {
const def = registry.getAction(selectedAction.type); let def = registry.getAction(selectedAction.type);
// Fallback: If action not found in registry, try without plugin prefix
if (!def && selectedAction.type.includes(".")) {
const baseType = selectedAction.type.split(".").pop();
if (baseType) {
def = registry.getAction(baseType);
}
}
// Final fallback: Create minimal definition from action data
if (!def) {
def = {
id: selectedAction.type,
type: selectedAction.type,
name: selectedAction.name,
description: `Action type: ${selectedAction.type}`,
category: selectedAction.category || "control",
icon: "Zap",
color: "#6366f1",
parameters: [],
source: selectedAction.source,
};
}
const categoryColors = { const categoryColors = {
wizard: "bg-blue-500", wizard: "bg-blue-500",
robot: "bg-emerald-500", robot: "bg-emerald-500",
@@ -198,12 +225,15 @@ export function PropertiesPanelBase({
const ResolvedIcon: React.ComponentType<{ className?: string }> = const ResolvedIcon: React.ComponentType<{ className?: string }> =
def?.icon && iconComponents[def.icon] def?.icon && iconComponents[def.icon]
? (iconComponents[def.icon] as React.ComponentType<{ ? (iconComponents[def.icon] as React.ComponentType<{
className?: string; className?: string;
}>) }>)
: Zap; : Zap;
return ( return (
<div className={cn("w-full min-w-0 space-y-3 px-3", className)} id="tour-designer-properties"> <div
className={cn("w-full min-w-0 space-y-3 px-3", className)}
id="tour-designer-properties"
>
{/* Header / Metadata */} {/* Header / Metadata */}
<div className="border-b pb-3"> <div className="border-b pb-3">
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
@@ -275,8 +305,269 @@ export function PropertiesPanelBase({
</div> </div>
</div> </div>
{/* Parameters */} {/* Branching Configuration (Special Case) */}
{def?.parameters.length ? ( {selectedAction.type === "branch" ? (
<div className="space-y-3">
<div className="text-muted-foreground flex items-center justify-between text-[10px] tracking-wide uppercase">
<span>Branch Options</span>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => {
const currentOptions =
((containingStep.trigger.conditions as any)
.options as any[]) || [];
const newOptions = [
...currentOptions,
{
label: "New Option",
nextStepId: design.steps[containingStep.order + 1]?.id,
variant: "default",
},
];
// Sync to Step Trigger (Source of Truth)
onStepUpdate(containingStep.id, {
trigger: {
...containingStep.trigger,
conditions: {
...containingStep.trigger.conditions,
options: newOptions,
},
},
});
// Sync to Action Params (for consistency)
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: {
...selectedAction.parameters,
options: newOptions,
},
});
}}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-3">
{(
((containingStep.trigger.conditions as any).options as any[]) ||
[]
).map((opt: any, idx: number) => (
<div
key={idx}
className="bg-muted/50 space-y-2 rounded border p-2"
>
<div className="grid grid-cols-5 gap-2">
<div className="col-span-3">
<Label className="text-[10px]">Label</Label>
<Input
value={opt.label}
onChange={(e) => {
const currentOptions =
((containingStep.trigger.conditions as any)
.options as any[]) || [];
const newOpts = [...currentOptions];
newOpts[idx] = {
...newOpts[idx],
label: e.target.value,
};
onStepUpdate(containingStep.id, {
trigger: {
...containingStep.trigger,
conditions: {
...containingStep.trigger.conditions,
options: newOpts,
},
},
});
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: {
...selectedAction.parameters,
options: newOpts,
},
});
}}
className="h-7 text-xs"
/>
</div>
<div className="col-span-2">
<Label className="text-[10px]">Target Step</Label>
{design.steps.length <= 1 ? (
<div
className="text-muted-foreground bg-muted/50 flex h-7 items-center truncate rounded border px-2 text-[10px]"
title="Add more steps to link"
>
No linkable steps
</div>
) : (
<Select
value={opt.nextStepId ?? ""}
onValueChange={(val) => {
const currentOptions =
((containingStep.trigger.conditions as any)
.options as any[]) || [];
const newOpts = [...currentOptions];
newOpts[idx] = { ...newOpts[idx], nextStepId: val };
onStepUpdate(containingStep.id, {
trigger: {
...containingStep.trigger,
conditions: {
...containingStep.trigger.conditions,
options: newOpts,
},
},
});
onActionUpdate(
containingStep.id,
selectedAction.id,
{
parameters: {
...selectedAction.parameters,
options: newOpts,
},
},
);
}}
>
<SelectTrigger className="h-7 w-full text-xs">
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent className="min-w-[180px]">
{design.steps.map((s) => (
<SelectItem
key={s.id}
value={s.id}
disabled={s.id === containingStep.id}
>
{s.order + 1}. {s.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
<div className="flex items-center justify-between">
<Select
value={opt.variant || "default"}
onValueChange={(val) => {
const currentOptions =
((containingStep.trigger.conditions as any)
.options as any[]) || [];
const newOpts = [...currentOptions];
newOpts[idx] = { ...newOpts[idx], variant: val };
onStepUpdate(containingStep.id, {
trigger: {
...containingStep.trigger,
conditions: {
...containingStep.trigger.conditions,
options: newOpts,
},
},
});
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: {
...selectedAction.parameters,
options: newOpts,
},
});
}}
>
<SelectTrigger className="h-6 w-[120px] text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default (Next)</SelectItem>
<SelectItem value="destructive">
Destructive (Red)
</SelectItem>
<SelectItem value="outline">Outline</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground h-6 w-6 p-0 hover:text-red-500"
onClick={() => {
const currentOptions =
((containingStep.trigger.conditions as any)
.options as any[]) || [];
const newOpts = [...currentOptions];
newOpts.splice(idx, 1);
onStepUpdate(containingStep.id, {
trigger: {
...containingStep.trigger,
conditions: {
...containingStep.trigger.conditions,
options: newOpts,
},
},
});
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: {
...selectedAction.parameters,
options: newOpts,
},
});
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
{!((containingStep.trigger.conditions as any).options as any[])
?.length && (
<div className="text-muted-foreground rounded border border-dashed py-4 text-center text-xs">
No options defined.
<br />
Click + to add a branch.
</div>
)}
</div>
</div>
) : selectedAction.type === "loop" ? (
/* Loop Configuration */
<div className="space-y-3">
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
Loop Configuration
</div>
<div className="space-y-4">
{/* Iterations */}
<div>
<Label className="text-xs">Iterations</Label>
<div className="mt-1 flex items-center gap-2">
<Slider
min={1}
max={20}
step={1}
value={[Number(selectedAction.parameters.iterations || 1)]}
onValueChange={(vals) => {
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: {
...selectedAction.parameters,
iterations: vals[0],
},
});
}}
/>
<span className="w-8 text-right font-mono text-xs">
{Number(selectedAction.parameters.iterations || 1)}
</span>
</div>
</div>
</div>
</div>
) : /* Standard Parameters */
def?.parameters.length ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="text-muted-foreground text-[10px] tracking-wide uppercase"> <div className="text-muted-foreground text-[10px] tracking-wide uppercase">
Parameters Parameters
@@ -295,7 +586,7 @@ export function PropertiesPanelBase({
}, },
}); });
}} }}
onCommit={() => { }} onCommit={() => {}}
/> />
))} ))}
</div> </div>
@@ -312,7 +603,10 @@ export function PropertiesPanelBase({
/* --------------------------- Step Properties View --------------------------- */ /* --------------------------- Step Properties View --------------------------- */
if (selectedStep) { if (selectedStep) {
return ( return (
<div className={cn("w-full min-w-0 space-y-3 px-3", className)} id="tour-designer-properties"> <div
className={cn("w-full min-w-0 space-y-3 px-3", className)}
id="tour-designer-properties"
>
<div className="border-b pb-2"> <div className="border-b pb-2">
<h3 className="flex items-center gap-2 text-sm font-medium"> <h3 className="flex items-center gap-2 text-sm font-medium">
<div <div
@@ -388,17 +682,19 @@ export function PropertiesPanelBase({
onValueChange={(val) => { onValueChange={(val) => {
onStepUpdate(selectedStep.id, { type: val as StepType }); onStepUpdate(selectedStep.id, { type: val as StepType });
}} }}
disabled={true}
> >
<SelectTrigger className="mt-1 h-7 w-full text-xs"> <SelectTrigger className="mt-1 h-7 w-full text-xs">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="sequential">Sequential</SelectItem> <SelectItem value="sequential">Sequential</SelectItem>
<SelectItem value="parallel">Parallel</SelectItem>
<SelectItem value="conditional">Conditional</SelectItem>
<SelectItem value="loop">Loop</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-muted-foreground mt-1 text-[10px]">
Steps always execute sequentially. Use control flow actions
for parallel/conditional logic.
</p>
</div> </div>
<div> <div>
<Label className="text-xs">Trigger</Label> <Label className="text-xs">Trigger</Label>
@@ -469,7 +765,7 @@ const ParameterEditor = React.memo(function ParameterEditor({
param, param,
value: rawValue, value: rawValue,
onUpdate, onUpdate,
onCommit onCommit,
}: ParameterEditorProps) { }: ParameterEditorProps) {
// Local state for immediate feedback // Local state for immediate feedback
const [localValue, setLocalValue] = useState<unknown>(rawValue); const [localValue, setLocalValue] = useState<unknown>(rawValue);
@@ -480,19 +776,22 @@ const ParameterEditor = React.memo(function ParameterEditor({
setLocalValue(rawValue); setLocalValue(rawValue);
}, [rawValue]); }, [rawValue]);
const handleUpdate = useCallback((newVal: unknown, immediate = false) => { const handleUpdate = useCallback(
setLocalValue(newVal); (newVal: unknown, immediate = false) => {
setLocalValue(newVal);
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
if (immediate) { if (immediate) {
onUpdate(newVal);
} else {
debounceRef.current = setTimeout(() => {
onUpdate(newVal); onUpdate(newVal);
}, 300); } else {
} debounceRef.current = setTimeout(() => {
}, [onUpdate]); onUpdate(newVal);
}, 300);
}
},
[onUpdate],
);
const handleCommit = useCallback(() => { const handleCommit = useCallback(() => {
if (localValue !== rawValue) { if (localValue !== rawValue) {
@@ -544,13 +843,22 @@ const ParameterEditor = React.memo(function ParameterEditor({
</div> </div>
); );
} else if (param.type === "number") { } else if (param.type === "number") {
const numericVal = typeof localValue === "number" ? localValue : (param.min ?? 0); const numericVal =
typeof localValue === "number" ? localValue : (param.min ?? 0);
if (param.min !== undefined || param.max !== undefined) { if (param.min !== undefined || param.max !== undefined) {
const min = param.min ?? 0; const min = param.min ?? 0;
const max = param.max ?? Math.max(min + 1, Number.isFinite(numericVal) ? numericVal : min + 1); const max =
param.max ??
Math.max(min + 1, Number.isFinite(numericVal) ? numericVal : min + 1);
const range = max - min; const range = max - min;
const step = param.step ?? (range <= 5 ? 0.1 : range <= 50 ? 0.5 : Math.max(1, Math.round(range / 100))); const step =
param.step ??
(range <= 5
? 0.1
: range <= 50
? 0.5
: Math.max(1, Math.round(range / 100)));
control = ( control = (
<div className="mt-1"> <div className="mt-1">
@@ -564,7 +872,9 @@ const ParameterEditor = React.memo(function ParameterEditor({
onPointerUp={() => handleUpdate(localValue)} // Commit on release onPointerUp={() => handleUpdate(localValue)} // Commit on release
/> />
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums"> <span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
{step < 1 ? Number(numericVal).toFixed(2) : Number(numericVal).toString()} {step < 1
? Number(numericVal).toFixed(2)
: Number(numericVal).toString()}
</span> </span>
</div> </div>
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]"> <div className="text-muted-foreground mt-1 flex justify-between text-[10px]">

View File

@@ -0,0 +1,53 @@
"use client";
import { SettingsTab } from "./tabs/SettingsTab";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
interface SettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
experiment: {
id: string;
name: string;
description: string | null;
status: string;
studyId: string;
createdAt: Date;
updatedAt: Date;
study: {
id: string;
name: string;
};
};
designStats?: {
stepCount: number;
actionCount: number;
};
}
export function SettingsModal({
open,
onOpenChange,
experiment,
designStats,
}: SettingsModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-4xl p-0">
<DialogHeader className="sr-only">
<DialogTitle>Experiment Settings</DialogTitle>
<DialogDescription>
Configure experiment metadata and status
</DialogDescription>
</DialogHeader>
<SettingsTab experiment={experiment} designStats={designStats} />
</DialogContent>
</Dialog>
);
}

View File

@@ -46,6 +46,10 @@ export interface ValidationPanelProps {
* Called to clear all issues for an entity. * Called to clear all issues for an entity.
*/ */
onEntityClear?: (entityId: string) => void; onEntityClear?: (entityId: string) => void;
/**
* Called to clear all issues globally.
*/
onClearAll?: () => void;
/** /**
* Optional function to map entity IDs to human-friendly names (e.g., step/action names). * Optional function to map entity IDs to human-friendly names (e.g., step/action names).
*/ */
@@ -60,25 +64,25 @@ export interface ValidationPanelProps {
const severityConfig = { const severityConfig = {
error: { error: {
icon: AlertCircle, icon: AlertCircle,
color: "text-red-600 dark:text-red-400", color: "text-validation-error-text",
bgColor: "bg-red-100 dark:bg-red-950/60", bgColor: "bg-validation-error-bg",
borderColor: "border-red-300 dark:border-red-700", borderColor: "border-validation-error-border",
badgeVariant: "destructive" as const, badgeVariant: "destructive" as const,
label: "Error", label: "Error",
}, },
warning: { warning: {
icon: AlertTriangle, icon: AlertTriangle,
color: "text-amber-600 dark:text-amber-400", color: "text-validation-warning-text",
bgColor: "bg-amber-100 dark:bg-amber-950/60", bgColor: "bg-validation-warning-bg",
borderColor: "border-amber-300 dark:border-amber-700", borderColor: "border-validation-warning-border",
badgeVariant: "secondary" as const, badgeVariant: "outline" as const,
label: "Warning", label: "Warning",
}, },
info: { info: {
icon: Info, icon: Info,
color: "text-blue-600 dark:text-blue-400", color: "text-validation-info-text",
bgColor: "bg-blue-100 dark:bg-blue-950/60", bgColor: "bg-validation-info-bg",
borderColor: "border-blue-300 dark:border-blue-700", borderColor: "border-validation-info-border",
badgeVariant: "outline" as const, badgeVariant: "outline" as const,
label: "Info", label: "Info",
}, },
@@ -102,8 +106,6 @@ function flattenIssues(issuesMap: Record<string, ValidationIssue[]>) {
return flattened; return flattened;
} }
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Issue Item Component */ /* Issue Item Component */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@@ -141,7 +143,7 @@ function IssueItem({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-[12px] leading-snug break-words whitespace-normal"> <p className="text-foreground text-[12px] leading-snug break-words whitespace-normal">
{issue.message} {issue.message}
</p> </p>
@@ -199,6 +201,7 @@ export function ValidationPanel({
onIssueClick, onIssueClick,
onIssueClear, onIssueClear,
onEntityClear: _onEntityClear, onEntityClear: _onEntityClear,
onClearAll,
entityLabelForId, entityLabelForId,
className, className,
}: ValidationPanelProps) { }: ValidationPanelProps) {
@@ -243,8 +246,6 @@ export function ValidationPanel({
console.log("[ValidationPanel] issues", issues, { flatIssues, counts }); console.log("[ValidationPanel] issues", issues, { flatIssues, counts });
}, [issues, flatIssues, counts]); }, [issues, flatIssues, counts]);
return ( return (
<div <div
className={cn( className={cn(

View File

@@ -0,0 +1,553 @@
"use client";
import React, { useMemo } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useDroppable } from "@dnd-kit/core";
import {
ChevronRight,
Trash2,
Clock,
GitBranch,
Repeat,
Layers,
List,
AlertCircle,
Play,
HelpCircle,
} from "lucide-react";
import { cn } from "~/lib/utils";
import { type ExperimentAction } from "~/lib/experiment-designer/types";
import { actionRegistry } from "../ActionRegistry";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { useDesignerStore } from "../state/store";
export interface ActionChipProps {
stepId: string;
action: ExperimentAction;
parentId: string | null;
selectedActionId: string | null | undefined;
onSelectAction: (stepId: string, actionId: string | undefined) => void;
onDeleteAction: (stepId: string, actionId: string) => void;
onReorderAction?: (
stepId: string,
actionId: string,
direction: "up" | "down",
) => void;
dragHandle?: boolean;
isFirst?: boolean;
isLast?: boolean;
}
export interface ActionChipVisualsProps {
action: ExperimentAction;
isSelected?: boolean;
isDragging?: boolean;
isOverNested?: boolean;
onSelect?: (e: React.MouseEvent) => void;
onDelete?: (e: React.MouseEvent) => void;
onReorder?: (direction: "up" | "down") => void;
dragHandleProps?: React.HTMLAttributes<HTMLElement>;
children?: React.ReactNode;
isFirst?: boolean;
isLast?: boolean;
validationStatus?: "error" | "warning" | "info";
}
/**
* Helper to determine visual style based on action type/category
*/
function getActionVisualStyle(action: ExperimentAction) {
const def = actionRegistry.getAction(action.type);
const category = def?.category || "other";
// Specific Control Types
if (action.type === "hristudio-core.wait" || action.type === "wait") {
return {
variant: "wait",
icon: Clock,
bg: "bg-amber-500/10 hover:bg-amber-500/20",
border: "border-amber-200 dark:border-amber-800",
text: "text-amber-700 dark:text-amber-400",
accent: "bg-amber-500",
};
}
if (action.type === "hristudio-core.branch" || action.type === "branch") {
return {
variant: "branch",
icon: GitBranch,
bg: "bg-orange-500/10 hover:bg-orange-500/20",
border: "border-orange-200 dark:border-orange-800",
text: "text-orange-700 dark:text-orange-400",
accent: "bg-orange-500",
};
}
if (action.type === "hristudio-core.loop" || action.type === "loop") {
return {
variant: "loop",
icon: Repeat,
bg: "bg-purple-500/10 hover:bg-purple-500/20",
border: "border-purple-200 dark:border-purple-800",
text: "text-purple-700 dark:text-purple-400",
accent: "bg-purple-500",
};
}
if (action.type === "hristudio-core.parallel" || action.type === "parallel") {
return {
variant: "parallel",
icon: Layers,
bg: "bg-emerald-500/10 hover:bg-emerald-500/20",
border: "border-emerald-200 dark:border-emerald-800",
text: "text-emerald-700 dark:text-emerald-400",
accent: "bg-emerald-500",
};
}
// General Categories
if (category === "wizard") {
return {
variant: "wizard",
icon: HelpCircle,
bg: "bg-indigo-500/5 hover:bg-indigo-500/10",
border: "border-indigo-200 dark:border-indigo-800",
text: "text-indigo-700 dark:text-indigo-300",
accent: "bg-indigo-500",
};
}
if (
(category as string) === "robot" ||
(category as string) === "movement" ||
(category as string) === "speech"
) {
return {
variant: "robot",
icon: Play, // Or specific robot icon if available
bg: "bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700",
border: "border-slate-200 dark:border-slate-700",
text: "text-slate-700 dark:text-slate-300",
accent: "bg-slate-500",
};
}
// Default
return {
variant: "default",
icon: undefined,
bg: "bg-muted/40 hover:bg-accent/40",
border: "border-border",
text: "text-foreground",
accent: "bg-muted-foreground",
};
}
export function ActionChipVisuals({
action,
isSelected,
isDragging,
isOverNested,
onSelect,
onDelete,
onReorder,
dragHandleProps,
children,
isFirst,
isLast,
validationStatus,
}: ActionChipVisualsProps) {
const def = actionRegistry.getAction(action.type);
const style = getActionVisualStyle(action);
const Icon = style.icon;
return (
<div
className={cn(
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px] transition-all duration-200",
style.bg,
style.border,
isSelected && "ring-primary border-primary bg-accent/50 ring-2",
isDragging && "scale-95 opacity-70 shadow-lg",
isOverNested &&
!isDragging &&
"bg-blue-50/50 ring-2 ring-blue-400 ring-offset-1 dark:bg-blue-900/20",
)}
onClick={onSelect}
role="button"
aria-pressed={isSelected}
tabIndex={0}
>
{/* Accent Bar logic for control flow */}
{style.variant !== "default" && style.variant !== "robot" && (
<div
className={cn(
"absolute top-0 bottom-0 left-0 w-1 rounded-l",
style.accent,
)}
/>
)}
<div
className={cn(
"flex w-full items-center gap-2",
style.variant !== "default" && style.variant !== "robot" && "pl-2",
)}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
{Icon && (
<Icon className={cn("h-3.5 w-3.5 flex-shrink-0", style.text)} />
)}
<span
className={cn(
"truncate leading-snug font-medium break-words",
style.text,
)}
>
{action.name}
</span>
{/* Inline Info for Control Actions */}
{style.variant === "wait" && !!action.parameters.duration && (
<span className="bg-background/50 text-muted-foreground ml-1 rounded px-1.5 py-0.5 font-mono text-[10px]">
{String(action.parameters.duration ?? "")}s
</span>
)}
{style.variant === "loop" && (
<span className="bg-background/50 text-muted-foreground ml-1 rounded px-1.5 py-0.5 font-mono text-[10px]">
{String(action.parameters.iterations || 1)}x
</span>
)}
{style.variant === "loop" &&
action.parameters.requireApproval !== false && (
<span
className="ml-1 flex items-center gap-0.5 rounded bg-purple-500/20 px-1.5 py-0.5 font-mono text-[10px] text-purple-700 dark:text-purple-300"
title="Requires Wizard Approval"
>
<HelpCircle className="h-2 w-2" />
Ask
</span>
)}
{validationStatus === "error" && (
<div
className="h-2 w-2 flex-shrink-0 rounded-full bg-red-500 ring-1 ring-red-600"
aria-label="Error"
/>
)}
{validationStatus === "warning" && (
<div
className="h-2 w-2 flex-shrink-0 rounded-full bg-amber-500 ring-1 ring-amber-600"
aria-label="Warning"
/>
)}
</div>
<div className="bg-background/50 border-border/50 mr-1 flex items-center gap-0.5 rounded-md border px-0.5 opacity-0 shadow-sm transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground pointer-events-auto z-20 h-5 w-5 p-0 text-[10px]"
onClick={(e) => {
e.stopPropagation();
onReorder?.("up");
}}
disabled={isFirst}
aria-label="Move action up"
>
<ChevronRight className="h-3 w-3 -rotate-90" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground pointer-events-auto z-20 h-5 w-5 p-0 text-[10px]"
onClick={(e) => {
e.stopPropagation();
onReorder?.("down");
}}
disabled={isLast}
aria-label="Move action down"
>
<ChevronRight className="h-3 w-3 rotate-90" />
</Button>
</div>
<button
type="button"
onClick={onDelete}
className="text-muted-foreground hover:text-destructive rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
aria-label="Delete action"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
{/* Description / Subtext */}
{def?.description && (
<div
className={cn(
"text-muted-foreground mt-0.5 line-clamp-2 w-full pl-2 text-[10px] leading-snug",
style.variant !== "default" && style.variant !== "robot" && "pl-4",
)}
>
{def.description}
</div>
)}
{/* Tags for parameters (hide for specialized control blocks that show inline) */}
{def?.parameters?.length &&
(style.variant === "default" || style.variant === "robot") ? (
<div className="flex flex-wrap gap-1 pt-1">
{def.parameters.slice(0, 3).map((p) => (
<span
key={p.id}
className="bg-background/80 text-muted-foreground ring-border max-w-[80px] truncate rounded px-1 py-0.5 text-[9px] font-medium ring-1"
>
{p.name}
</span>
))}
{def.parameters.length > 3 && (
<span className="text-muted-foreground text-[9px]">
+{def.parameters.length - 3}
</span>
)}
</div>
) : null}
{children}
</div>
);
}
export function SortableActionChip({
stepId,
action,
parentId,
selectedActionId,
onSelectAction,
onDeleteAction,
onReorderAction,
dragHandle,
isFirst,
isLast,
}: ActionChipProps) {
const isSelected = selectedActionId === action.id;
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
const steps = useDesignerStore((s) => s.steps);
const currentStep = steps.find((s) => s.id === stepId);
// Branch Options Visualization
const branchOptions = useMemo(() => {
if (!action.type.includes("branch") || !currentStep) return null;
const options = (currentStep.trigger as any)?.conditions?.options;
if (
!options?.length &&
!(currentStep.trigger as any)?.conditions?.nextStepId
) {
return (
<div className="text-muted-foreground/60 bg-background/50 mt-2 rounded border border-dashed py-2 text-center text-[10px] italic">
No branches configured. Add options in properties.
</div>
);
}
// Combine explicit options and unconditional nextStepId
// The original FlowWorkspace logic iterated options. logic there:
// (step.trigger.conditions as any).options.map...
return (
<div className="mt-2 w-full space-y-1">
{options?.map((opt: any, idx: number) => {
// Resolve ID to name for display
let targetName = "Unlinked";
let targetIndex = -1;
if (opt.nextStepId) {
const target = steps.find((s) => s.id === opt.nextStepId);
if (target) {
targetName = target.name;
targetIndex = target.order;
}
} else if (typeof opt.nextStepIndex === "number") {
targetIndex = opt.nextStepIndex;
targetName = `Step #${targetIndex + 1}`;
}
return (
<div
key={idx}
className="bg-background/50 flex items-center justify-between rounded border p-1.5 text-[10px] shadow-sm"
>
<div className="flex min-w-0 items-center gap-2">
<Badge
variant="outline"
className={cn(
"bg-background min-w-[60px] justify-center px-1 py-0 text-[9px] font-bold tracking-wider uppercase",
opt.variant === "destructive"
? "border-red-500/30 text-red-600 dark:text-red-400"
: "text-foreground border-slate-500/30",
)}
>
{opt.label}
</Badge>
<ChevronRight className="text-muted-foreground/50 h-3 w-3 flex-shrink-0" />
</div>
<div className="flex max-w-[60%] min-w-0 items-center justify-end gap-1.5 text-right">
<span
className="text-foreground/80 truncate font-medium"
title={targetName}
>
{targetName}
</span>
{targetIndex !== -1 && (
<Badge
variant="secondary"
className="h-3.5 min-w-[18px] justify-center bg-slate-100 px-1 py-0 text-[9px] tabular-nums dark:bg-slate-800"
>
#{targetIndex + 1}
</Badge>
)}
</div>
</div>
);
})}
{/* Visual indicator for unconditional jump if present and no options matched (though usually logic handles this) */}
{/* For now keeping parity with FlowWorkspace which only showed options */}
</div>
);
}, [action.type, currentStep, steps]);
const displayChildren = useMemo(() => {
if (
insertionProjection?.stepId === stepId &&
insertionProjection.parentId === action.id
) {
const copy = [...(action.children || [])];
copy.splice(insertionProjection.index, 0, insertionProjection.action);
return copy;
}
return action.children || [];
}, [action.children, action.id, stepId, insertionProjection]);
/* ------------------------------------------------------------------------ */
/* Main Sortable Logic */
/* ------------------------------------------------------------------------ */
const isPlaceholder = action.id === "projection-placeholder";
// Compute validation status
const issues = useDesignerStore((s) => s.validationIssues[action.id]);
const validationStatus = useMemo(() => {
if (!issues?.length) return undefined;
if (issues.some((i) => i.severity === "error")) return "error";
if (issues.some((i) => i.severity === "warning")) return "warning";
return "info";
}, [issues]);
/* ------------------------------------------------------------------------ */
/* Sortable (Local) DnD Monitoring */
/* ------------------------------------------------------------------------ */
// useSortable disabled per user request to remove action drag-and-drop
// const { ... } = useSortable(...)
// Use local dragging state or passed prop
const isDragging = dragHandle || false;
/* ------------------------------------------------------------------------ */
/* Nested Droppable (for control flow containers) */
/* ------------------------------------------------------------------------ */
const def = actionRegistry.getAction(action.type);
const nestedDroppableId = `container-${action.id}`;
const { isOver: isOverNested, setNodeRef: setNestedNodeRef } = useDroppable({
id: nestedDroppableId,
disabled: !def?.nestable || isPlaceholder, // Disable droppable for placeholder
data: {
type: "container",
stepId,
parentId: action.id,
action, // Pass full action for projection logic
},
});
const shouldRenderChildren = !!def?.nestable;
if (isPlaceholder) {
return (
<div
className={cn(
"relative flex w-full flex-col items-start gap-1 rounded border border-dashed px-3 py-2 text-[11px]",
"border-blue-400 bg-blue-50/50 opacity-70 dark:bg-blue-900/20",
)}
>
<div className="flex w-full items-center gap-2">
<span className="font-medium text-blue-700 italic">
{action.name}
</span>
</div>
</div>
);
}
return (
<ActionChipVisuals
action={action}
isSelected={isSelected}
isDragging={isDragging}
isOverNested={isOverNested && !isDragging}
onSelect={(e) => {
e.stopPropagation();
onSelectAction(stepId, action.id);
}}
onDelete={(e) => {
e.stopPropagation();
onDeleteAction(stepId, action.id);
}}
onReorder={(direction) => onReorderAction?.(stepId, action.id, direction)}
isFirst={isFirst}
isLast={isLast}
validationStatus={validationStatus}
>
{/* Branch Options Visualization */}
{branchOptions}
{/* Nested Children Rendering (e.g. for Loops/Parallel) */}
{shouldRenderChildren && (
<div
ref={setNestedNodeRef}
className={cn(
"mt-2 w-full space-y-2 rounded border border-dashed p-1.5 transition-colors",
isOverNested
? "border-blue-400 bg-blue-100/50 dark:bg-blue-900/20"
: "bg-muted/20 dark:bg-muted/10 border-border/50",
)}
>
{displayChildren?.length === 0 ? (
<div className="text-muted-foreground/60 py-2 text-center text-[10px] italic">
Empty container
</div>
) : (
displayChildren?.map((child, idx) => (
<SortableActionChip
key={child.id}
stepId={stepId}
action={child}
parentId={action.id}
selectedActionId={selectedActionId}
onSelectAction={onSelectAction}
onDeleteAction={onDeleteAction}
onReorderAction={onReorderAction}
isFirst={idx === 0}
isLast={idx === (displayChildren?.length || 0) - 1}
/>
))
)}
</div>
)}
</ActionChipVisuals>
);
}

View File

@@ -8,6 +8,7 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { import {
useDndContext,
useDroppable, useDroppable,
useDndMonitor, useDndMonitor,
type DragEndEvent, type DragEndEvent,
@@ -28,6 +29,8 @@ import {
Trash2, Trash2,
GitBranch, GitBranch,
Edit3, Edit3,
CornerDownRight,
Repeat,
} from "lucide-react"; } from "lucide-react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { import {
@@ -39,6 +42,7 @@ import { actionRegistry } from "../ActionRegistry";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { SortableActionChip } from "./ActionChip";
/** /**
* FlowWorkspace * FlowWorkspace
@@ -80,21 +84,32 @@ export interface VirtualItem {
interface StepRowProps { interface StepRowProps {
item: VirtualItem; item: VirtualItem;
step: ExperimentStep; // Explicit pass for freshness
totalSteps: number;
selectedStepId: string | null | undefined; selectedStepId: string | null | undefined;
selectedActionId: string | null | undefined; selectedActionId: string | null | undefined;
renamingStepId: string | null; renamingStepId: string | null;
onSelectStep: (id: string | undefined) => void; onSelectStep: (id: string | undefined) => void;
onSelectAction: (stepId: string, actionId: string | undefined) => void; onSelectAction: (stepId: string, actionId: string | undefined) => void;
onToggleExpanded: (step: ExperimentStep) => void; onToggleExpanded: (step: ExperimentStep) => void;
onRenameStep: (step: ExperimentStep, name: string) => void; onRenameStep: (step: ExperimentStep, newName: string) => void;
onDeleteStep: (step: ExperimentStep) => void; onDeleteStep: (step: ExperimentStep) => void;
onDeleteAction: (stepId: string, actionId: string) => void; onDeleteAction: (stepId: string, actionId: string) => void;
setRenamingStepId: (id: string | null) => void; setRenamingStepId: (id: string | null) => void;
registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void; registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void;
onReorderStep: (stepId: string, direction: "up" | "down") => void;
onReorderAction?: (
stepId: string,
actionId: string,
direction: "up" | "down",
) => void;
isChild?: boolean;
} }
const StepRow = React.memo(function StepRow({ function StepRow({
item, item,
step,
totalSteps,
selectedStepId, selectedStepId,
selectedActionId, selectedActionId,
renamingStepId, renamingStepId,
@@ -106,8 +121,12 @@ const StepRow = React.memo(function StepRow({
onDeleteAction, onDeleteAction,
setRenamingStepId, setRenamingStepId,
registerMeasureRef, registerMeasureRef,
onReorderStep,
onReorderAction,
isChild,
}: StepRowProps) { }: StepRowProps) {
const step = item.step; // const step = item.step; // Removed local derivation
const allSteps = useDesignerStore((s) => s.steps);
const insertionProjection = useDesignerStore((s) => s.insertionProjection); const insertionProjection = useDesignerStore((s) => s.insertionProjection);
const displayActions = useMemo(() => { const displayActions = useMemo(() => {
@@ -125,47 +144,39 @@ const StepRow = React.memo(function StepRow({
return step.actions; return step.actions;
}, [step.actions, step.id, insertionProjection]); }, [step.actions, step.id, insertionProjection]);
const {
setNodeRef,
transform,
transition,
attributes,
listeners,
isDragging,
} = useSortable({
id: sortableStepId(step.id),
data: {
type: "step",
step: step,
},
});
const style: React.CSSProperties = { const style: React.CSSProperties = {
position: "absolute", position: "absolute",
top: item.top, top: item.top,
left: 0, left: 0,
right: 0, right: 0,
width: "100%", width: "100%",
transform: CSS.Transform.toString(transform), transition: "top 300ms cubic-bezier(0.4, 0, 0.2, 1)",
transition, // transform: CSS.Transform.toString(transform), // Removed
zIndex: isDragging ? 25 : undefined, // zIndex: isDragging ? 25 : undefined,
}; };
return ( return (
<div ref={setNodeRef} style={style} data-step-id={step.id}> <div style={style} data-step-id={step.id}>
<div <div
ref={(el) => registerMeasureRef(step.id, el)} ref={(el) => registerMeasureRef(step.id, el)}
className="relative px-3 py-4" className={cn(
"relative px-3 py-4 transition-all duration-300",
isChild && "ml-8 pl-0",
)}
data-step-id={step.id} data-step-id={step.id}
> >
{isChild && (
<div className="text-muted-foreground/40 absolute top-8 left-[-24px]">
<CornerDownRight className="h-5 w-5" />
</div>
)}
<StepDroppableArea stepId={step.id} /> <StepDroppableArea stepId={step.id} />
<div <div
className={cn( className={cn(
"mb-2 rounded border shadow-sm transition-colors", "mb-2 rounded-lg border shadow-sm transition-colors",
selectedStepId === step.id selectedStepId === step.id
? "border-border bg-accent/30" ? "border-border bg-accent/30"
: "hover:bg-accent/30", : "hover:bg-accent/30",
isDragging && "opacity-80 ring-1 ring-blue-300",
)} )}
> >
<div <div
@@ -213,7 +224,7 @@ const StepRow = React.memo(function StepRow({
onRenameStep( onRenameStep(
step, step,
(e.target as HTMLInputElement).value.trim() || (e.target as HTMLInputElement).value.trim() ||
step.name, step.name,
); );
setRenamingStepId(null); setRenamingStepId(null);
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
@@ -258,17 +269,85 @@ const StepRow = React.memo(function StepRow({
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
<div <Button
className="text-muted-foreground cursor-grab p-1" variant="ghost"
aria-label="Drag step" size="sm"
{...attributes} className="text-muted-foreground hover:text-foreground h-7 w-7 p-0 text-[11px]"
{...listeners} onClick={(e) => {
e.stopPropagation();
onReorderStep(step.id, "up");
}}
disabled={item.index === 0}
aria-label="Move step up"
> >
<GripVertical className="h-4 w-4" /> <ChevronRight className="h-4 w-4 -rotate-90" />
</div> </Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground h-7 w-7 p-0 text-[11px]"
onClick={(e) => {
e.stopPropagation();
onReorderStep(step.id, "down");
}}
disabled={item.index === totalSteps - 1}
aria-label="Move step down"
>
<ChevronRight className="h-4 w-4 rotate-90" />
</Button>
</div> </div>
</div> </div>
{/* Conditional Branching Visualization */}
{/* Loop Visualization */}
{step.type === "loop" && (
<div
className="mx-3 my-3 rounded-md border text-xs"
style={{
backgroundColor: "var(--validation-info-bg, #f0f9ff)",
borderColor: "var(--validation-info-border, #bae6fd)",
}}
>
<div
className="flex items-center gap-2 border-b px-3 py-2 font-medium"
style={{
borderColor: "var(--validation-info-border, #bae6fd)",
color: "var(--validation-info-text, #0369a1)",
}}
>
<Repeat className="h-3.5 w-3.5" />
<span>Loop Logic</span>
</div>
<div className="space-y-2 p-2">
<div className="flex items-center gap-2 text-[11px]">
<span className="text-muted-foreground">Repeat:</span>
<Badge variant="outline" className="font-mono">
{(step.trigger.conditions as any).loop?.iterations || 1}{" "}
times
</Badge>
</div>
<div className="flex items-center gap-2 text-[11px]">
<span className="text-muted-foreground">Approval:</span>
<Badge
variant={
(step.trigger.conditions as any).loop?.requireApproval !==
false
? "default"
: "secondary"
}
>
{(step.trigger.conditions as any).loop?.requireApproval !==
false
? "Required"
: "Auto-proceed"}
</Badge>
</div>
</div>
</div>
)}
{/* Action List (Collapsible/Virtual content) */} {/* Action List (Collapsible/Virtual content) */}
{step.expanded && ( {step.expanded && (
<div className="bg-background/40 min-h-[3rem] space-y-2 p-2 pb-8"> <div className="bg-background/40 min-h-[3rem] space-y-2 p-2 pb-8">
@@ -278,11 +357,11 @@ const StepRow = React.memo(function StepRow({
> >
<div className="flex w-full flex-col gap-2"> <div className="flex w-full flex-col gap-2">
{displayActions.length === 0 ? ( {displayActions.length === 0 ? (
<div className="flex h-12 items-center justify-center rounded border border-dashed text-xs text-muted-foreground"> <div className="text-muted-foreground flex h-12 items-center justify-center rounded border border-dashed text-xs">
Drop actions here Drop actions here
</div> </div>
) : ( ) : (
displayActions.map((action) => ( displayActions.map((action, index) => (
<SortableActionChip <SortableActionChip
key={action.id} key={action.id}
stepId={step.id} stepId={step.id}
@@ -291,6 +370,9 @@ const StepRow = React.memo(function StepRow({
selectedActionId={selectedActionId} selectedActionId={selectedActionId}
onSelectAction={onSelectAction} onSelectAction={onSelectAction}
onDeleteAction={onDeleteAction} onDeleteAction={onDeleteAction}
onReorderAction={onReorderAction}
isFirst={index === 0}
isLast={index === displayActions.length - 1}
/> />
)) ))
)} )}
@@ -302,7 +384,57 @@ const StepRow = React.memo(function StepRow({
</div> </div>
</div> </div>
); );
}); }
/* -------------------------------------------------------------------------- */
/* Step Card Preview (for DragOverlay) */
/* -------------------------------------------------------------------------- */
export function StepCardPreview({
step,
dragHandle,
}: {
step: ExperimentStep;
dragHandle?: boolean;
}) {
return (
<div
className={cn(
"bg-background rounded-lg border shadow-xl ring-2 ring-blue-500/20",
dragHandle && "cursor-grabbing",
)}
>
<div className="flex items-center justify-between gap-2 border-b p-3 px-2 py-1.5">
<div className="flex items-center gap-2">
<div className="text-muted-foreground rounded p-1">
<ChevronRight className="h-4 w-4" />
</div>
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] font-normal"
>
{step.order + 1}
</Badge>
<div className="flex items-center gap-1">
<span className="text-sm font-medium">{step.name}</span>
</div>
<span className="text-muted-foreground hidden text-[11px] md:inline">
{step.actions.length} actions
</span>
</div>
<div className="text-muted-foreground flex items-center gap-1">
<GripVertical className="h-4 w-4" />
</div>
</div>
{/* Preview optional: show empty body hint or just the header? Header is usually enough for sorting. */}
<div className="bg-muted/10 flex h-12 items-center justify-center border-t border-dashed p-2">
<span className="text-muted-foreground text-[10px]">
{step.actions.length} actions hidden while dragging
</span>
</div>
</div>
);
}
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Utility */ /* Utility */
@@ -312,8 +444,6 @@ function generateStepId(): string {
return `step-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; return `step-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
} }
function sortableStepId(stepId: string) { function sortableStepId(stepId: string) {
return `s-step-${stepId}`; return `s-step-${stepId}`;
} }
@@ -331,256 +461,26 @@ function parseSortableAction(id: string): string | null {
/* Droppable Overlay (for palette action drops) */ /* Droppable Overlay (for palette action drops) */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
function StepDroppableArea({ stepId }: { stepId: string }) { function StepDroppableArea({ stepId }: { stepId: string }) {
const { isOver } = useDroppable({ id: `step-${stepId}` }); const { active } = useDndContext();
return ( const isStepDragging = active?.id.toString().startsWith("s-step-");
<div
data-step-drop
className={cn(
"pointer-events-none absolute inset-0 rounded-md transition-colors",
isOver &&
"bg-blue-50/40 ring-2 ring-blue-400/60 ring-offset-0 dark:bg-blue-950/20",
)}
/>
);
}
/* -------------------------------------------------------------------------- */ const { isOver, setNodeRef } = useDroppable({
/* Sortable Action Chip */ id: `step-${stepId}`,
/* -------------------------------------------------------------------------- */ disabled: isStepDragging,
interface ActionChipProps {
stepId: string;
action: ExperimentAction;
parentId: string | null;
selectedActionId: string | null | undefined;
onSelectAction: (stepId: string, actionId: string | undefined) => void;
onDeleteAction: (stepId: string, actionId: string) => void;
dragHandle?: boolean;
}
function SortableActionChip({
stepId,
action,
parentId,
selectedActionId,
onSelectAction,
onDeleteAction,
dragHandle,
}: ActionChipProps) {
const def = actionRegistry.getAction(action.type);
const isSelected = selectedActionId === action.id;
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
const displayChildren = useMemo(() => {
if (
insertionProjection?.stepId === stepId &&
insertionProjection.parentId === action.id
) {
const copy = [...(action.children || [])];
copy.splice(insertionProjection.index, 0, insertionProjection.action);
return copy;
}
return action.children;
}, [action.children, action.id, stepId, insertionProjection]);
/* ------------------------------------------------------------------------ */
/* Main Sortable Logic */
/* ------------------------------------------------------------------------ */
const isPlaceholder = action.id === "projection-placeholder";
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging: isSortableDragging,
} = useSortable({
id: sortableActionId(action.id),
disabled: isPlaceholder, // Disable sortable for placeholder
data: {
type: "action",
stepId,
parentId,
id: action.id,
},
}); });
// Use local dragging state or passed prop if (isStepDragging) return null;
const isDragging = isSortableDragging || dragHandle;
const style = {
transform: CSS.Translate.toString(transform),
transition,
};
/* ------------------------------------------------------------------------ */
/* Nested Droppable (for control flow containers) */
/* ------------------------------------------------------------------------ */
const nestedDroppableId = `container-${action.id}`;
const {
isOver: isOverNested,
setNodeRef: setNestedNodeRef
} = useDroppable({
id: nestedDroppableId,
disabled: !def?.nestable || isPlaceholder, // Disable droppable for placeholder
data: {
type: "container",
stepId,
parentId: action.id,
action // Pass full action for projection logic
}
});
const shouldRenderChildren = def?.nestable;
if (isPlaceholder) {
const { setNodeRef: setPlaceholderRef } = useDroppable({
id: "projection-placeholder",
data: { type: "placeholder" }
});
// Render simplified placeholder without hooks refs
// We still render the content matching the action type for visual fidelity
return (
<div
ref={setPlaceholderRef}
className="group relative flex w-full flex-col items-start gap-1 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 px-3 py-2 text-[11px] opacity-70"
>
<div className="flex w-full items-center gap-2">
<span className={cn(
"h-2.5 w-2.5 rounded-full",
def ? {
wizard: "bg-blue-500",
robot: "bg-emerald-500",
control: "bg-amber-500",
observation: "bg-purple-500",
}[def.category] : "bg-gray-400"
)} />
<span className="font-medium text-foreground">{def?.name ?? action.name}</span>
</div>
{def?.description && (
<div className="text-muted-foreground line-clamp-3 w-full text-[10px] leading-snug">
{def.description}
</div>
)}
</div>
);
}
return ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} data-step-drop
className={cn( className={cn(
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px]", "pointer-events-none absolute inset-0 rounded-md transition-colors",
"bg-muted/40 hover:bg-accent/40 cursor-pointer", isOver &&
isSelected && "border-border bg-accent/30", "bg-blue-50/40 ring-2 ring-blue-400/60 ring-offset-0 dark:bg-blue-950/20",
isDragging && "opacity-70 shadow-lg",
// Visual feedback for nested drop
isOverNested && !isDragging && "ring-2 ring-blue-400 ring-offset-1 bg-blue-50/50"
)} )}
onClick={(e) => { />
e.stopPropagation();
onSelectAction(stepId, action.id);
}}
{...attributes}
role="button"
aria-pressed={isSelected}
tabIndex={0}
>
<div className="flex w-full items-center gap-2">
<div
{...listeners}
className="text-muted-foreground/70 hover:text-foreground cursor-grab rounded p-0.5"
aria-label="Drag action"
>
<GripVertical className="h-3.5 w-3.5" />
</div>
<span
className={cn(
"h-2.5 w-2.5 rounded-full",
def
? {
wizard: "bg-blue-500",
robot: "bg-emerald-500",
control: "bg-amber-500",
observation: "bg-purple-500",
}[def.category]
: "bg-slate-400",
)}
/>
<span className="flex-1 leading-snug font-medium break-words">
{action.name}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDeleteAction(stepId, action.id);
}}
className="text-muted-foreground hover:text-foreground rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
aria-label="Delete action"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
{def?.description && (
<div className="text-muted-foreground line-clamp-3 w-full text-[10px] leading-snug">
{def.description}
</div>
)}
{def?.parameters.length ? (
<div className="flex flex-wrap gap-1 pt-0.5">
{def.parameters.slice(0, 4).map((p) => (
<span
key={p.id}
className="bg-background/70 text-muted-foreground ring-border rounded px-1 py-0.5 text-[9px] font-medium ring-1"
>
{p.name}
</span>
))}
{def.parameters.length > 4 && (
<span className="text-[9px] text-muted-foreground">+{def.parameters.length - 4}</span>
)}
</div>
) : null}
{/* Nested Actions Container */}
{shouldRenderChildren && (
<div
ref={setNestedNodeRef}
className={cn(
"mt-2 w-full flex flex-col gap-2 pl-4 border-l-2 border-border/40 transition-all min-h-[0.5rem] pb-4",
)}
>
<SortableContext
items={(displayChildren ?? action.children ?? [])
.filter(c => c.id !== "projection-placeholder")
.map(c => sortableActionId(c.id))}
strategy={verticalListSortingStrategy}
>
{(displayChildren || action.children || []).map((child) => (
<SortableActionChip
key={child.id}
stepId={stepId}
action={child}
parentId={action.id}
selectedActionId={selectedActionId}
onSelectAction={onSelectAction}
onDeleteAction={onDeleteAction}
/>
))}
{(!displayChildren?.length && !action.children?.length) && (
<div className="text-[10px] text-muted-foreground/60 italic py-1">
Drag actions here
</div>
)}
</SortableContext>
</div>
)}
</div>
); );
} }
@@ -633,6 +533,24 @@ export function FlowWorkspace({
return map; return map;
}, [steps]); }, [steps]);
/* Hierarchy detection for visual indentation */
const childStepIds = useMemo(() => {
const children = new Set<string>();
for (const step of steps) {
if (
step.type === "conditional" &&
(step.trigger.conditions as any)?.options
) {
for (const opt of (step.trigger.conditions as any).options) {
if (opt.nextStepId) {
children.add(opt.nextStepId);
}
}
}
}
return children;
}, [steps]);
/* Resize observer for viewport and width changes */ /* Resize observer for viewport and width changes */
useLayoutEffect(() => { useLayoutEffect(() => {
const el = containerRef.current; const el = containerRef.current;
@@ -796,6 +714,58 @@ export function FlowWorkspace({
[removeAction, selectedActionId, selectAction, recomputeHash], [removeAction, selectedActionId, selectAction, recomputeHash],
); );
const handleReorderStep = useCallback(
(stepId: string, direction: "up" | "down") => {
console.log("handleReorderStep", stepId, direction);
const currentIndex = steps.findIndex((s) => s.id === stepId);
console.log("currentIndex", currentIndex, "total", steps.length);
if (currentIndex === -1) return;
const newIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
console.log("newIndex", newIndex);
if (newIndex < 0 || newIndex >= steps.length) return;
reorderStep(currentIndex, newIndex);
},
[steps, reorderStep],
);
const handleReorderAction = useCallback(
(stepId: string, actionId: string, direction: "up" | "down") => {
const step = steps.find((s) => s.id === stepId);
if (!step) return;
const findInTree = (
list: ExperimentAction[],
pId: string | null,
): {
list: ExperimentAction[];
parentId: string | null;
index: number;
} | null => {
const idx = list.findIndex((a) => a.id === actionId);
if (idx !== -1) return { list, parentId: pId, index: idx };
for (const a of list) {
if (a.children) {
const res = findInTree(a.children, a.id);
if (res) return res;
}
}
return null;
};
const context = findInTree(step.actions, null);
if (!context) return;
const { parentId, index, list } = context;
const newIndex = direction === "up" ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= list.length) return;
moveAction(stepId, actionId, parentId, newIndex);
},
[steps, moveAction],
);
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
/* Sortable (Local) DnD Monitoring */ /* Sortable (Local) DnD Monitoring */
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
@@ -815,32 +785,25 @@ export function FlowWorkspace({
} }
const activeId = active.id.toString(); const activeId = active.id.toString();
const overId = over.id.toString(); const overId = over.id.toString();
// Step reorder
if (activeId.startsWith("s-step-") && overId.startsWith("s-step-")) { // Step reorder is now handled globally in DesignerRoot
const fromStepId = parseSortableStep(activeId);
const toStepId = parseSortableStep(overId);
if (fromStepId && toStepId && fromStepId !== toStepId) {
const fromIndex = steps.findIndex((s) => s.id === fromStepId);
const toIndex = steps.findIndex((s) => s.id === toStepId);
if (fromIndex >= 0 && toIndex >= 0) {
reorderStep(fromIndex, toIndex);
void recomputeHash();
}
}
}
// Action reorder (supports nesting) // Action reorder (supports nesting)
if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) { if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) {
const activeData = active.data.current; const activeData = active.data.current;
const overData = over.data.current; const overData = over.data.current;
if ( if (
activeData && overData && activeData &&
overData &&
activeData.stepId === overData.stepId && activeData.stepId === overData.stepId &&
activeData.type === 'action' && overData.type === 'action' activeData.type === "action" &&
overData.type === "action"
) { ) {
const stepId = activeData.stepId as string; const stepId = activeData.stepId as string;
const activeActionId = activeData.action.id; // Fix: SortableActionChip puts 'id' directly on data, not inside 'action' property
const overActionId = overData.action.id; const activeActionId = activeData.id;
const overActionId = overData.id;
if (activeActionId !== overActionId) { if (activeActionId !== overActionId) {
const newParentId = overData.parentId as string | null; const newParentId = overData.parentId as string | null;
@@ -874,11 +837,13 @@ export function FlowWorkspace({
if ( if (
activeData && activeData &&
overData && overData &&
activeData.type === 'action' && activeData.type === "action" &&
overData.type === 'action' overData.type === "action"
) { ) {
const activeActionId = activeData.action.id; // Fix: Access 'id' directly from data payload
const overActionId = overData.action.id; const activeActionId = activeData.id;
const overActionId = overData.id;
const activeStepId = activeData.stepId; const activeStepId = activeData.stepId;
const overStepId = overData.stepId; const overStepId = overData.stepId;
const activeParentId = activeData.parentId; const activeParentId = activeData.parentId;
@@ -888,12 +853,17 @@ export function FlowWorkspace({
if (activeParentId !== overParentId || activeStepId !== overStepId) { if (activeParentId !== overParentId || activeStepId !== overStepId) {
// Determine new index // Determine new index
// verification of safe move handled by store // verification of safe move handled by store
moveAction(overStepId, activeActionId, overParentId, overData.sortable.index); moveAction(
overStepId,
activeActionId,
overParentId,
overData.sortable.index,
);
} }
} }
} }
}, },
[moveAction] [moveAction],
); );
useDndMonitor({ useDndMonitor({
@@ -956,7 +926,8 @@ export function FlowWorkspace({
<div <div
ref={containerRef} ref={containerRef}
id="tour-designer-canvas" id="tour-designer-canvas"
className="relative h-0 min-h-0 flex-1 overflow-x-hidden overflow-y-auto" // Removed 'border' class to fix double border issue
className="relative h-0 min-h-0 flex-1 overflow-x-hidden overflow-y-auto rounded-md"
onScroll={onScroll} onScroll={onScroll}
> >
{steps.length === 0 ? ( {steps.length === 0 ? (
@@ -990,6 +961,8 @@ export function FlowWorkspace({
<StepRow <StepRow
key={vi.key} key={vi.key}
item={vi} item={vi}
step={vi.step}
totalSteps={steps.length}
selectedStepId={selectedStepId} selectedStepId={selectedStepId}
selectedActionId={selectedActionId} selectedActionId={selectedActionId}
renamingStepId={renamingStepId} renamingStepId={renamingStepId}
@@ -1004,6 +977,9 @@ export function FlowWorkspace({
onDeleteAction={deleteAction} onDeleteAction={deleteAction}
setRenamingStepId={setRenamingStepId} setRenamingStepId={setRenamingStepId}
registerMeasureRef={registerMeasureRef} registerMeasureRef={registerMeasureRef}
onReorderStep={handleReorderStep}
onReorderAction={handleReorderAction}
isChild={childStepIds.has(vi.step.id)}
/> />
), ),
)} )}
@@ -1017,4 +993,3 @@ export function FlowWorkspace({
// Wrap in React.memo to prevent unnecessary re-renders causing flashing // Wrap in React.memo to prevent unnecessary re-renders causing flashing
export default React.memo(FlowWorkspace); export default React.memo(FlowWorkspace);

View File

@@ -5,14 +5,11 @@ import {
Save, Save,
RefreshCw, RefreshCw,
Download, Download,
Hash,
AlertTriangle, AlertTriangle,
CheckCircle2, CheckCircle2,
UploadCloud, Hash,
Wand2,
Sparkles,
GitBranch, GitBranch,
Keyboard, Sparkles,
} from "lucide-react"; } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
@@ -20,21 +17,6 @@ import { Separator } from "~/components/ui/separator";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { useDesignerStore } from "../state/store"; import { useDesignerStore } from "../state/store";
/**
* BottomStatusBar
*
* Compact, persistent status + quick-action bar for the Experiment Designer.
* Shows:
* - Validation / drift / unsaved state
* - Short design hash & version
* - Aggregate counts (steps / actions)
* - Last persisted hash (if available)
* - Quick actions (Save, Validate, Export, Command Palette)
*
* The bar is intentionally UI-only: callback props are used so that higher-level
* orchestration (e.g. DesignerRoot / Shell) controls actual side effects.
*/
export interface BottomStatusBarProps { export interface BottomStatusBarProps {
onSave?: () => void; onSave?: () => void;
onValidate?: () => void; onValidate?: () => void;
@@ -45,9 +27,6 @@ export interface BottomStatusBarProps {
saving?: boolean; saving?: boolean;
validating?: boolean; validating?: boolean;
exporting?: boolean; exporting?: boolean;
/**
* Optional externally supplied last saved Date for relative display.
*/
lastSavedAt?: Date; lastSavedAt?: Date;
} }
@@ -55,24 +34,16 @@ export function BottomStatusBar({
onSave, onSave,
onValidate, onValidate,
onExport, onExport,
onOpenCommandPalette,
onRecalculateHash,
className, className,
saving, saving,
validating, validating,
exporting, exporting,
lastSavedAt,
}: BottomStatusBarProps) { }: BottomStatusBarProps) {
/* ------------------------------------------------------------------------ */
/* Store Selectors */
/* ------------------------------------------------------------------------ */
const steps = useDesignerStore((s) => s.steps); const steps = useDesignerStore((s) => s.steps);
const lastPersistedHash = useDesignerStore((s) => s.lastPersistedHash); const lastPersistedHash = useDesignerStore((s) => s.lastPersistedHash);
const currentDesignHash = useDesignerStore((s) => s.currentDesignHash); const currentDesignHash = useDesignerStore((s) => s.currentDesignHash);
const lastValidatedHash = useDesignerStore((s) => s.lastValidatedHash); const lastValidatedHash = useDesignerStore((s) => s.lastValidatedHash);
const pendingSave = useDesignerStore((s) => s.pendingSave); const pendingSave = useDesignerStore((s) => s.pendingSave);
const versionStrategy = useDesignerStore((s) => s.versionStrategy);
const autoSaveEnabled = useDesignerStore((s) => s.autoSaveEnabled);
const actionCount = useMemo( const actionCount = useMemo(
() => steps.reduce((sum, st) => sum + st.actions.length, 0), () => steps.reduce((sum, st) => sum + st.actions.length, 0),
@@ -93,64 +64,28 @@ export function BottomStatusBar({
return "valid"; return "valid";
}, [currentDesignHash, lastValidatedHash]); }, [currentDesignHash, lastValidatedHash]);
const shortHash = useMemo(
() => (currentDesignHash ? currentDesignHash.slice(0, 8) : "—"),
[currentDesignHash],
);
const lastPersistedShort = useMemo(
() => (lastPersistedHash ? lastPersistedHash.slice(0, 8) : null),
[lastPersistedHash],
);
/* ------------------------------------------------------------------------ */
/* Derived Display Helpers */
/* ------------------------------------------------------------------------ */
function formatRelative(date?: Date): string {
if (!date) return "—";
const now = Date.now();
const diffMs = now - date.getTime();
if (diffMs < 30_000) return "just now";
const mins = Math.floor(diffMs / 60_000);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
const relSaved = formatRelative(lastSavedAt);
const validationBadge = (() => { const validationBadge = (() => {
switch (validationStatus) { switch (validationStatus) {
case "valid": case "valid":
return ( return (
<Badge <div className="flex items-center gap-1.5 text-green-600 dark:text-green-400">
variant="outline" <CheckCircle2 className="h-3.5 w-3.5" />
className="border-green-400 text-green-600 dark:text-green-400" <span className="hidden sm:inline">Valid</span>
title="Validated (hash stable)" </div>
>
<CheckCircle2 className="mr-1 h-3 w-3" />
<span className="hidden sm:inline">Validated</span>
</Badge>
); );
case "drift": case "drift":
return ( return (
<Badge <div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
variant="destructive" <AlertTriangle className="h-3.5 w-3.5" />
className="border-amber-400 bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400" <span className="hidden sm:inline">Modified</span>
title="Drift since last validation" </div>
>
<AlertTriangle className="mr-1 h-3 w-3" />
<span className="hidden sm:inline">Drift</span>
</Badge>
); );
default: default:
return ( return (
<Badge variant="outline" title="Not validated yet"> <div className="text-muted-foreground flex items-center gap-1.5">
<Hash className="mr-1 h-3 w-3" /> <Hash className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Unvalidated</span> <span className="hidden sm:inline">Unvalidated</span>
</Badge> </div>
); );
} }
})(); })();
@@ -159,190 +94,63 @@ export function BottomStatusBar({
hasUnsaved && !pendingSave ? ( hasUnsaved && !pendingSave ? (
<Badge <Badge
variant="outline" variant="outline"
className="border-orange-300 text-orange-600 dark:text-orange-400" className="h-5 gap-1 border-orange-300 px-1.5 text-[10px] font-normal text-orange-600 dark:text-orange-400"
title="Unsaved changes"
> >
<AlertTriangle className="mr-1 h-3 w-3" /> Unsaved
<span className="hidden sm:inline">Unsaved</span>
</Badge> </Badge>
) : null; ) : null;
const savingIndicator = const savingIndicator =
pendingSave || saving ? ( pendingSave || saving ? (
<Badge <div className="text-muted-foreground flex animate-pulse items-center gap-1.5">
variant="secondary" <RefreshCw className="h-3 w-3 animate-spin" />
className="animate-pulse" <span>Saving...</span>
title="Saving changes" </div>
>
<RefreshCw className="mr-1 h-3 w-3 animate-spin" />
Saving
</Badge>
) : null; ) : null;
/* ------------------------------------------------------------------------ */
/* Handlers */
/* ------------------------------------------------------------------------ */
const handleSave = useCallback(() => {
if (onSave) onSave();
}, [onSave]);
const handleValidate = useCallback(() => {
if (onValidate) onValidate();
}, [onValidate]);
const handleExport = useCallback(() => {
if (onExport) onExport();
}, [onExport]);
const handlePalette = useCallback(() => {
if (onOpenCommandPalette) onOpenCommandPalette();
}, [onOpenCommandPalette]);
const handleRecalculateHash = useCallback(() => {
if (onRecalculateHash) onRecalculateHash();
}, [onRecalculateHash]);
/* ------------------------------------------------------------------------ */
/* Render */
/* ------------------------------------------------------------------------ */
return ( return (
<div <div
className={cn( className={cn(
"border-border/60 bg-muted/40 supports-[backdrop-filter]:bg-muted/30 backdrop-blur", "border-border/60 bg-muted/40 supports-[backdrop-filter]:bg-muted/30 backdrop-blur",
"flex h-10 w-full flex-shrink-0 items-center gap-3 border-t px-3 text-xs", "flex h-9 w-full flex-shrink-0 items-center gap-4 border-t px-3 text-xs font-medium",
"font-medium",
className, className,
)} )}
aria-label="Designer status bar"
> >
{/* Left Cluster: Validation & Hash */} {/* Status Indicators */}
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-3">
{validationBadge} {validationBadge}
{unsavedBadge} {unsavedBadge}
{savingIndicator} {savingIndicator}
<Separator orientation="vertical" className="h-4" />
<div
className="flex items-center gap-1 font-mono text-[11px]"
title="Current design hash"
>
<Hash className="text-muted-foreground h-3 w-3" />
{shortHash}
{lastPersistedShort && lastPersistedShort !== shortHash && (
<span
className="text-muted-foreground/70"
title="Last persisted hash"
>
/ {lastPersistedShort}
</span>
)}
</div>
</div> </div>
{/* Middle Cluster: Aggregate Counts */} <Separator orientation="vertical" className="h-4 opacity-50" />
<div className="text-muted-foreground flex min-w-0 items-center gap-3 truncate">
<div {/* Stats */}
className="flex items-center gap-1" <div className="text-muted-foreground flex items-center gap-3 truncate">
title="Steps in current design" <span className="flex items-center gap-1.5">
> <GitBranch className="h-3.5 w-3.5 opacity-70" />
<GitBranch className="h-3 w-3" />
{steps.length} {steps.length}
<span className="hidden sm:inline"> steps</span> </span>
</div> <span className="flex items-center gap-1.5">
<div <Sparkles className="h-3.5 w-3.5 opacity-70" />
className="flex items-center gap-1"
title="Total actions across all steps"
>
<Sparkles className="h-3 w-3" />
{actionCount} {actionCount}
<span className="hidden sm:inline"> actions</span> </span>
</div>
<div
className="hidden items-center gap-1 sm:flex"
title="Auto-save setting"
>
<UploadCloud className="h-3 w-3" />
{autoSaveEnabled ? "auto-save on" : "auto-save off"}
</div>
<div
className="hidden items-center gap-1 font-mono text-[10px] sm:flex"
title={`Current design hash: ${currentDesignHash ?? 'Not computed'}`}
>
<Hash className="h-3 w-3" />
{currentDesignHash?.slice(0, 16) ?? '—'}
<Button
variant="ghost"
size="sm"
className="h-5 px-1 ml-1"
onClick={handleRecalculateHash}
aria-label="Recalculate hash"
title="Recalculate hash"
>
<RefreshCw className="h-3 w-3" />
</Button>
</div>
<div
className="text-muted-foreground/80 hidden items-center gap-1 text-[10px] font-normal tracking-wide md:flex"
title="Relative time since last save"
>
Saved {relSaved}
</div>
</div> </div>
{/* Flexible Spacer */}
<div className="flex-1" /> <div className="flex-1" />
{/* Right Cluster: Quick Actions */} {/* Actions */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 px-2" className="h-7 px-2 text-xs"
disabled={!hasUnsaved && !pendingSave} onClick={onExport}
onClick={handleSave}
aria-label="Save (s)"
title="Save (s)"
>
<Save className="mr-1 h-3 w-3" />
<span className="hidden sm:inline">Save</span>
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleValidate}
disabled={validating}
aria-label="Validate (v)"
title="Validate (v)"
>
<RefreshCw
className={cn("mr-1 h-3 w-3", validating && "animate-spin")}
/>
<span className="hidden sm:inline">Validate</span>
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleExport}
disabled={exporting} disabled={exporting}
aria-label="Export (e)" title="Export JSON"
title="Export (e)"
> >
<Download className="mr-1 h-3 w-3" /> <Download className="mr-1.5 h-3.5 w-3.5" />
<span className="hidden sm:inline">Export</span> Export
</Button>
<Separator orientation="vertical" className="mx-1 h-4" />
<Button
variant="outline"
size="sm"
className="h-7 px-2"
onClick={handlePalette}
aria-label="Command Palette (⌘K)"
title="Command Palette (⌘K)"
>
<Keyboard className="mr-1 h-3 w-3" />
<span className="hidden sm:inline">Commands</span>
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,9 @@
import * as React from "react"; import * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import { Button } from "~/components/ui/button";
import { PanelLeft, Settings2 } from "lucide-react";
type Edge = "left" | "right"; type Edge = "left" | "right";
export interface PanelsContainerProps { export interface PanelsContainerProps {
@@ -36,6 +38,14 @@ export interface PanelsContainerProps {
/** Keyboard resize step (fractional) per arrow press; Shift increases by 2x */ /** Keyboard resize step (fractional) per arrow press; Shift increases by 2x */
keyboardStepPct?: number; keyboardStepPct?: number;
/**
* Controlled collapse state
*/
leftCollapsed?: boolean;
rightCollapsed?: boolean;
onLeftCollapseChange?: (collapsed: boolean) => void;
onRightCollapseChange?: (collapsed: boolean) => void;
} }
/** /**
@@ -43,6 +53,7 @@ export interface PanelsContainerProps {
* *
* Tailwind-first, grid-based panel layout with: * Tailwind-first, grid-based panel layout with:
* - Drag-resizable left/right panels (no persistence) * - Drag-resizable left/right panels (no persistence)
* - Collapsible side panels
* - Strict overflow containment (no page-level x-scroll) * - Strict overflow containment (no page-level x-scroll)
* - Internal y-scroll for each panel * - Internal y-scroll for each panel
* - Optional visual dividers on the center panel only (prevents double borders) * - Optional visual dividers on the center panel only (prevents double borders)
@@ -53,29 +64,30 @@ export interface PanelsContainerProps {
* - Resize handles are absolutely positioned over the grid at the left and right boundaries. * - Resize handles are absolutely positioned over the grid at the left and right boundaries.
* - Fractions are clamped with configurable min/max so panels remain usable at all sizes. * - Fractions are clamped with configurable min/max so panels remain usable at all sizes.
*/ */
const Panel: React.FC<React.PropsWithChildren<{ const Panel: React.FC<
className?: string; React.PropsWithChildren<{
panelClassName?: string; className?: string;
contentClassName?: string; panelClassName?: string;
}>> = ({ contentClassName?: string;
className: panelCls, }>
panelClassName, > = ({ className: panelCls, panelClassName, contentClassName, children }) => (
contentClassName, <section
children, className={cn(
}) => ( "min-w-0 overflow-hidden transition-[width,opacity] duration-300 ease-in-out",
<section panelCls,
className={cn("min-w-0 overflow-hidden", panelCls, panelClassName)} panelClassName,
)}
>
<div
className={cn(
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
contentClassName,
)}
> >
<div {children}
className={cn( </div>
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto", </section>
contentClassName, );
)}
>
{children}
</div>
</section>
);
export function PanelsContainer({ export function PanelsContainer({
left, left,
@@ -91,6 +103,10 @@ export function PanelsContainer({
minRightPct = 0.12, minRightPct = 0.12,
maxRightPct = 0.33, maxRightPct = 0.33,
keyboardStepPct = 0.02, keyboardStepPct = 0.02,
leftCollapsed = false,
rightCollapsed = false,
onLeftCollapseChange,
onRightCollapseChange,
}: PanelsContainerProps) { }: PanelsContainerProps) {
const hasLeft = Boolean(left); const hasLeft = Boolean(left);
const hasRight = Boolean(right); const hasRight = Boolean(right);
@@ -116,20 +132,39 @@ export function PanelsContainer({
(lp: number, rp: number) => { (lp: number, rp: number) => {
if (!hasCenter) return { l: 0, c: 0, r: 0 }; if (!hasCenter) return { l: 0, c: 0, r: 0 };
// Effective widths (0 if collapsed)
const effectiveL = leftCollapsed ? 0 : lp;
const effectiveR = rightCollapsed ? 0 : rp;
// When logic runs, we must clamp the *underlying* percentages (lp, rp)
// but return 0 for the CSS vars if collapsed.
// Actually, if collapsed, we just want the CSS var to be 0.
// But we maintain the state `leftPct` so it restores correctly.
if (hasLeft && hasRight) { if (hasLeft && hasRight) {
const l = clamp(lp, minLeftPct, maxLeftPct); // Standard clamp (on the state values)
const r = clamp(rp, minRightPct, maxRightPct); const lState = clamp(lp, minLeftPct, maxLeftPct);
const c = Math.max(0.1, 1 - (l + r)); // always preserve some center space const rState = clamp(rp, minRightPct, maxRightPct);
// Effective output
const l = leftCollapsed ? 0 : lState;
const r = rightCollapsed ? 0 : rState;
// Center takes remainder
const c = 1 - (l + r);
return { l, c, r }; return { l, c, r };
} }
if (hasLeft && !hasRight) { if (hasLeft && !hasRight) {
const l = clamp(lp, minLeftPct, maxLeftPct); const lState = clamp(lp, minLeftPct, maxLeftPct);
const c = Math.max(0.2, 1 - l); const l = leftCollapsed ? 0 : lState;
const c = 1 - l;
return { l, c, r: 0 }; return { l, c, r: 0 };
} }
if (!hasLeft && hasRight) { if (!hasLeft && hasRight) {
const r = clamp(rp, minRightPct, maxRightPct); const rState = clamp(rp, minRightPct, maxRightPct);
const c = Math.max(0.2, 1 - r); const r = rightCollapsed ? 0 : rState;
const c = 1 - r;
return { l: 0, c, r }; return { l: 0, c, r };
} }
// Center only // Center only
@@ -143,6 +178,8 @@ export function PanelsContainer({
maxLeftPct, maxLeftPct,
minRightPct, minRightPct,
maxRightPct, maxRightPct,
leftCollapsed,
rightCollapsed,
], ],
); );
@@ -157,10 +194,10 @@ export function PanelsContainer({
const deltaPx = e.clientX - d.startX; const deltaPx = e.clientX - d.startX;
const deltaPct = deltaPx / d.containerWidth; const deltaPct = deltaPx / d.containerWidth;
if (d.edge === "left" && hasLeft) { if (d.edge === "left" && hasLeft && !leftCollapsed) {
const nextLeft = clamp(d.startLeft + deltaPct, minLeftPct, maxLeftPct); const nextLeft = clamp(d.startLeft + deltaPct, minLeftPct, maxLeftPct);
setLeftPct(nextLeft); setLeftPct(nextLeft);
} else if (d.edge === "right" && hasRight) { } else if (d.edge === "right" && hasRight && !rightCollapsed) {
// Dragging the right edge moves leftwards as delta increases // Dragging the right edge moves leftwards as delta increases
const nextRight = clamp( const nextRight = clamp(
d.startRight - deltaPct, d.startRight - deltaPct,
@@ -170,7 +207,16 @@ export function PanelsContainer({
setRightPct(nextRight); setRightPct(nextRight);
} }
}, },
[hasLeft, hasRight, minLeftPct, maxLeftPct, minRightPct, maxRightPct], [
hasLeft,
hasRight,
minLeftPct,
maxLeftPct,
minRightPct,
maxRightPct,
leftCollapsed,
rightCollapsed,
],
); );
const endDrag = React.useCallback(() => { const endDrag = React.useCallback(() => {
@@ -213,14 +259,14 @@ export function PanelsContainer({
const step = (e.shiftKey ? 2 : 1) * keyboardStepPct; const step = (e.shiftKey ? 2 : 1) * keyboardStepPct;
if (edge === "left" && hasLeft) { if (edge === "left" && hasLeft && !leftCollapsed) {
const next = clamp( const next = clamp(
leftPct + (e.key === "ArrowRight" ? step : -step), leftPct + (e.key === "ArrowRight" ? step : -step),
minLeftPct, minLeftPct,
maxLeftPct, maxLeftPct,
); );
setLeftPct(next); setLeftPct(next);
} else if (edge === "right" && hasRight) { } else if (edge === "right" && hasRight && !rightCollapsed) {
const next = clamp( const next = clamp(
rightPct + (e.key === "ArrowLeft" ? step : -step), rightPct + (e.key === "ArrowLeft" ? step : -step),
minRightPct, minRightPct,
@@ -231,111 +277,177 @@ export function PanelsContainer({
}; };
// CSS variables for the grid fractions // CSS variables for the grid fractions
// We use FR units instead of % to let the browser handle exact pixel fitting without rounding errors causing overflow
const styleVars: React.CSSProperties & Record<string, string> = hasCenter const styleVars: React.CSSProperties & Record<string, string> = hasCenter
? { ? {
"--col-left": `${(hasLeft ? l : 0) * 100}%`, "--col-left": `${hasLeft ? l : 0}fr`,
"--col-center": `${c * 100}%`, "--col-center": `${c}fr`,
"--col-right": `${(hasRight ? r : 0) * 100}%`, "--col-right": `${hasRight ? r : 0}fr`,
} }
: {}; : {};
// Explicit grid template depending on which side panels exist // Explicit grid template depending on which side panels exist
const gridAreas =
hasLeft && hasRight
? '"left center right"'
: hasLeft && !hasRight
? '"left center"'
: !hasLeft && hasRight
? '"center right"'
: '"center"';
const gridCols = const gridCols =
hasLeft && hasRight hasLeft && hasRight
? "[grid-template-columns:minmax(0,var(--col-left))_minmax(0,var(--col-center))_minmax(0,var(--col-right))]" ? "[grid-template-columns:var(--col-left)_var(--col-center)_var(--col-right)]"
: hasLeft && !hasRight : hasLeft && !hasRight
? "[grid-template-columns:minmax(0,var(--col-left))_minmax(0,var(--col-center))]" ? "[grid-template-columns:var(--col-left)_var(--col-center)]"
: !hasLeft && hasRight : !hasLeft && hasRight
? "[grid-template-columns:minmax(0,var(--col-center))_minmax(0,var(--col-right))]" ? "[grid-template-columns:var(--col-center)_var(--col-right)]"
: "[grid-template-columns:minmax(0,1fr)]"; : "[grid-template-columns:1fr]";
// Dividers on the center panel only (prevents double borders if children have their own borders) // Dividers on the center panel only (prevents double borders if children have their own borders)
const centerDividers = const centerDividers =
showDividers && hasCenter showDividers && hasCenter
? cn({ ? cn({
"border-l": hasLeft, "border-l": hasLeft,
"border-r": hasRight, "border-r": hasRight,
}) })
: undefined; : undefined;
return ( return (
<div <>
ref={rootRef} {/* Mobile Layout (Flex + Sheets) */}
aria-label={ariaLabel} <div className={cn("flex h-full w-full flex-col md:hidden", className)}>
style={styleVars} {/* Mobile Header/Toolbar for access to panels */}
className={cn( <div className="bg-background flex items-center justify-between border-b px-4 py-2">
"relative grid h-full min-h-0 w-full overflow-hidden select-none", <div className="flex items-center gap-2">
gridCols, {hasLeft && (
className, <Sheet>
)} <SheetTrigger asChild>
> <Button variant="outline" size="icon" className="h-8 w-8">
{hasLeft && ( <PanelLeft className="h-4 w-4" />
<Panel </Button>
panelClassName={panelClassName} </SheetTrigger>
contentClassName={contentClassName} <SheetContent side="left" className="w-[85vw] p-0 sm:max-w-md">
> <div className="h-full overflow-hidden">{left}</div>
{left} </SheetContent>
</Panel> </Sheet>
)} )}
<span className="text-sm font-medium">Designer</span>
</div>
{hasCenter && ( {hasRight && (
<Panel <Sheet>
className={centerDividers} <SheetTrigger asChild>
panelClassName={panelClassName} <Button variant="outline" size="icon" className="h-8 w-8">
contentClassName={contentClassName} <Settings2 className="h-4 w-4" />
> </Button>
</SheetTrigger>
<SheetContent side="right" className="w-[85vw] p-0 sm:max-w-md">
<div className="h-full overflow-hidden">{right}</div>
</SheetContent>
</Sheet>
)}
</div>
{/* Main Content (Center) */}
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden">
{center} {center}
</Panel> </div>
)} </div>
{hasRight && ( {/* Desktop Layout (Grid) */}
<Panel <div
panelClassName={panelClassName} ref={rootRef}
contentClassName={contentClassName} aria-label={ariaLabel}
> className={cn(
{right} "relative hidden h-full min-h-0 w-full max-w-full overflow-hidden select-none md:grid",
</Panel> // 2-3-2 ratio for left-center-right panels when all visible
)} hasLeft &&
hasRight &&
!leftCollapsed &&
!rightCollapsed &&
"grid-cols-[2fr_3fr_2fr]",
// Left collapsed: center + right (3:2 ratio)
hasLeft &&
hasRight &&
leftCollapsed &&
!rightCollapsed &&
"grid-cols-[3fr_2fr]",
// Right collapsed: left + center (2:3 ratio)
hasLeft &&
hasRight &&
!leftCollapsed &&
rightCollapsed &&
"grid-cols-[2fr_3fr]",
// Both collapsed: center only
hasLeft &&
hasRight &&
leftCollapsed &&
rightCollapsed &&
"grid-cols-1",
// Only left and center
hasLeft && !hasRight && !leftCollapsed && "grid-cols-[2fr_3fr]",
hasLeft && !hasRight && leftCollapsed && "grid-cols-1",
// Only center and right
!hasLeft && hasRight && !rightCollapsed && "grid-cols-[3fr_2fr]",
!hasLeft && hasRight && rightCollapsed && "grid-cols-1",
// Only center
!hasLeft && !hasRight && "grid-cols-1",
className,
)}
>
{hasLeft && !leftCollapsed && (
<Panel
panelClassName={panelClassName}
contentClassName={contentClassName}
>
{left}
</Panel>
)}
{/* Resize handles (only render where applicable) */} {hasCenter && (
{hasCenter && hasLeft && ( <Panel
<button className={centerDividers}
type="button" panelClassName={panelClassName}
role="separator" contentClassName={contentClassName}
aria-label="Resize left panel" >
aria-orientation="vertical" {center}
onPointerDown={startDrag("left")} </Panel>
onKeyDown={onKeyResize("left")} )}
className={cn(
"absolute inset-y-0 z-10 w-1 cursor-col-resize outline-none",
"focus-visible:ring-ring focus-visible:ring-2",
)}
// Position at the boundary between left and center
style={{ left: "var(--col-left)", transform: "translateX(-0.5px)" }}
tabIndex={0}
/>
)}
{hasCenter && hasRight && ( {hasRight && !rightCollapsed && (
<button <Panel
type="button" panelClassName={panelClassName}
role="separator" contentClassName={contentClassName}
aria-label="Resize right panel" >
aria-orientation="vertical" {right}
onPointerDown={startDrag("right")} </Panel>
onKeyDown={onKeyResize("right")} )}
className={cn(
"absolute inset-y-0 z-10 w-1 cursor-col-resize outline-none", {/* Resize Handles */}
"focus-visible:ring-ring focus-visible:ring-2", {hasLeft && !leftCollapsed && (
)} <button
// Position at the boundary between center and right (offset from the right) type="button"
style={{ right: "var(--col-right)", transform: "translateX(0.5px)" }} className="absolute top-0 bottom-0 z-50 -ml-0.75 w-1.5 cursor-col-resize transition-colors hover:bg-blue-400/50 focus:outline-none"
tabIndex={0} style={{ left: "var(--col-left)" }}
/> onPointerDown={startDrag("left")}
)} onKeyDown={onKeyResize("left")}
</div> aria-label="Resize left panel"
/>
)}
{hasRight && !rightCollapsed && (
<button
type="button"
className="absolute top-0 bottom-0 z-50 -mr-0.75 w-1.5 cursor-col-resize transition-colors hover:bg-blue-400/50 focus:outline-none"
style={{ right: "var(--col-right)" }}
onPointerDown={startDrag("right")}
onKeyDown={onKeyResize("right")}
aria-label="Resize right panel"
/>
)}
</div>
</>
); );
} }

View File

@@ -22,6 +22,7 @@ import {
Eye, Eye,
X, X,
Layers, Layers,
PanelLeftClose,
} from "lucide-react"; } from "lucide-react";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@@ -88,8 +89,8 @@ function DraggableAction({
const style: React.CSSProperties = transform const style: React.CSSProperties = transform
? { ? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
} }
: {}; : {};
const IconComponent = iconMap[action.icon] ?? Sparkles; const IconComponent = iconMap[action.icon] ?? Sparkles;
@@ -108,7 +109,7 @@ function DraggableAction({
{...listeners} {...listeners}
style={style} style={style}
className={cn( className={cn(
"group bg-background/60 hover:bg-accent/50 relative flex w-full cursor-grab touch-none flex-col gap-1 rounded border px-2 text-left transition-colors select-none", "group bg-background/60 hover:bg-accent/50 relative flex w-full cursor-grab touch-none flex-col gap-1 rounded-lg border px-2 text-left transition-colors select-none",
compact ? "py-1.5 text-[11px]" : "py-2 text-[12px]", compact ? "py-1.5 text-[11px]" : "py-2 text-[12px]",
isDragging && "opacity-50", isDragging && "opacity-50",
)} )}
@@ -168,7 +169,15 @@ function DraggableAction({
); );
} }
export function ActionLibraryPanel() { export interface ActionLibraryPanelProps {
collapsed?: boolean;
onCollapse?: (collapsed: boolean) => void;
}
export function ActionLibraryPanel({
collapsed,
onCollapse,
}: ActionLibraryPanelProps = {}) {
const registry = useActionRegistry(); const registry = useActionRegistry();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -293,8 +302,6 @@ export function ActionLibraryPanel() {
setShowOnlyFavorites(false); setShowOnlyFavorites(false);
}, [categories]); }, [categories]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const activeCats = selectedCategories; const activeCats = selectedCategories;
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
@@ -333,7 +340,10 @@ export function ActionLibraryPanel() {
).length; ).length;
return ( return (
<div className="flex h-full flex-col overflow-hidden" id="tour-designer-blocks"> <div
className="flex h-full flex-col overflow-hidden"
id="tour-designer-blocks"
>
<div className="bg-background/60 flex-shrink-0 border-b p-2"> <div className="bg-background/60 flex-shrink-0 border-b p-2">
<div className="relative mb-2"> <div className="relative mb-2">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" /> <Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
@@ -487,4 +497,3 @@ export function ActionLibraryPanel() {
// Wrap in React.memo to prevent unnecessary re-renders causing flashing in categories // Wrap in React.memo to prevent unnecessary re-renders causing flashing in categories
export default React.memo(ActionLibraryPanel); export default React.memo(ActionLibraryPanel);

View File

@@ -2,6 +2,7 @@
import React, { useMemo, useState, useCallback } from "react"; import React, { useMemo, useState, useCallback } from "react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { useDesignerStore } from "../state/store"; import { useDesignerStore } from "../state/store";
@@ -18,7 +19,9 @@ import {
AlertTriangle, AlertTriangle,
GitBranch, GitBranch,
PackageSearch, PackageSearch,
PanelRightClose,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner";
/** /**
* InspectorPanel * InspectorPanel
@@ -47,6 +50,11 @@ export interface InspectorPanelProps {
* Called when user changes tab (only if activeTab not externally controlled). * Called when user changes tab (only if activeTab not externally controlled).
*/ */
onTabChange?: (tab: "properties" | "issues" | "dependencies") => void; onTabChange?: (tab: "properties" | "issues" | "dependencies") => void;
/**
* Collapse state and handler
*/
collapsed?: boolean;
onCollapse?: (collapsed: boolean) => void;
/** /**
* If true, auto-switch to "properties" when a selection occurs. * If true, auto-switch to "properties" when a selection occurs.
*/ */
@@ -60,6 +68,10 @@ export interface InspectorPanelProps {
name: string; name: string;
version: string; version: string;
}>; }>;
/**
* Called to clear all validation issues.
*/
onClearAll?: () => void;
} }
export function InspectorPanel({ export function InspectorPanel({
@@ -68,6 +80,9 @@ export function InspectorPanel({
onTabChange, onTabChange,
autoFocusOnSelection = true, autoFocusOnSelection = true,
studyPlugins, studyPlugins,
collapsed,
onCollapse,
onClearAll,
}: InspectorPanelProps) { }: InspectorPanelProps) {
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
/* Store Selectors */ /* Store Selectors */
@@ -314,6 +329,7 @@ export function InspectorPanel({
> >
<ValidationPanel <ValidationPanel
issues={validationIssues} issues={validationIssues}
onClearAll={onClearAll}
entityLabelForId={(entityId) => { entityLabelForId={(entityId) => {
if (entityId.startsWith("action-")) { if (entityId.startsWith("action-")) {
for (const s of steps) { for (const s of steps) {
@@ -356,15 +372,13 @@ export function InspectorPanel({
actionDefinitions={actionRegistry.getAllActions()} actionDefinitions={actionRegistry.getAllActions()}
studyPlugins={studyPlugins} studyPlugins={studyPlugins}
onReconcileAction={(actionId) => { onReconcileAction={(actionId) => {
// Placeholder: future diff modal / signature update toast.info("Action Reconcile coming soon!");
console.log("Reconcile TODO for action:", actionId);
}} }}
onRefreshDependencies={() => { onRefreshDependencies={() => {
console.log("Refresh dependencies TODO"); toast.info("Refresh dependencies coming soon!");
}} }}
onInstallPlugin={(pluginId) => { onInstallPlugin={(pluginId) => {
console.log("Install plugin TODO:", pluginId); toast.info("Install plugin coming soon!");
}} }}
/> />
</div> </div>

View File

@@ -155,9 +155,12 @@ function projectActionForDesign(
pluginVersion: action.source.pluginVersion, pluginVersion: action.source.pluginVersion,
baseActionId: action.source.baseActionId, baseActionId: action.source.baseActionId,
}, },
execution: action.execution ? projectExecutionDescriptor(action.execution) : null, execution: action.execution
? projectExecutionDescriptor(action.execution)
: null,
parameterKeysOrValues: parameterProjection, parameterKeysOrValues: parameterProjection,
children: action.children?.map(c => projectActionForDesign(c, options)) ?? [], children:
action.children?.map((c) => projectActionForDesign(c, options)) ?? [],
}; };
if (options.includeActionNames) { if (options.includeActionNames) {
@@ -176,16 +179,16 @@ function projectExecutionDescriptor(
timeoutMs: exec.timeoutMs ?? null, timeoutMs: exec.timeoutMs ?? null,
ros2: exec.ros2 ros2: exec.ros2
? { ? {
topic: exec.ros2.topic ?? null, topic: exec.ros2.topic ?? null,
service: exec.ros2.service ?? null, service: exec.ros2.service ?? null,
action: exec.ros2.action ?? null, action: exec.ros2.action ?? null,
} }
: null, : null,
rest: exec.rest rest: exec.rest
? { ? {
method: exec.rest.method, method: exec.rest.method,
path: exec.rest.path, path: exec.rest.path,
} }
: null, : null,
}; };
} }
@@ -203,8 +206,7 @@ function projectStepForDesign(
order: step.order, order: step.order,
trigger: { trigger: {
type: step.trigger.type, type: step.trigger.type,
// Only the sorted keys of conditions (structural presence) conditions: canonicalize(step.trigger.conditions),
conditionKeys: Object.keys(step.trigger.conditions).sort(),
}, },
actions: step.actions.map((a) => projectActionForDesign(a, options)), actions: step.actions.map((a) => projectActionForDesign(a, options)),
}; };
@@ -245,12 +247,14 @@ export async function computeActionSignature(
baseActionId: def.baseActionId ?? null, baseActionId: def.baseActionId ?? null,
execution: def.execution execution: def.execution
? { ? {
transport: def.execution.transport, transport: def.execution.transport,
retryable: def.execution.retryable ?? false, retryable: def.execution.retryable ?? false,
timeoutMs: def.execution.timeoutMs ?? null, timeoutMs: def.execution.timeoutMs ?? null,
} }
: null,
schema: def.parameterSchemaRaw
? canonicalize(def.parameterSchemaRaw)
: null, : null,
schema: def.parameterSchemaRaw ? canonicalize(def.parameterSchemaRaw) : null,
}; };
return hashObject(projection); return hashObject(projection);
} }
@@ -267,11 +271,39 @@ export async function computeDesignHash(
opts: DesignHashOptions = {}, opts: DesignHashOptions = {},
): Promise<string> { ): Promise<string> {
const options = { ...DEFAULT_OPTIONS, ...opts }; const options = { ...DEFAULT_OPTIONS, ...opts };
const projected = steps
.slice() // 1. Sort steps first to ensure order independence of input array
.sort((a, b) => a.order - b.order) const sortedSteps = steps.slice().sort((a, b) => a.order - b.order);
.map((s) => projectStepForDesign(s, options));
return hashObject({ steps: projected }); // 2. Map hierarchically (Merkle style)
const stepHashes = await Promise.all(
sortedSteps.map(async (s) => {
// Action hashes
const actionHashes = await Promise.all(
s.actions.map((a) => hashObject(projectActionForDesign(a, options))),
);
// Step hash
const pStep = {
id: s.id,
type: s.type,
order: s.order,
trigger: {
type: s.trigger.type,
conditions: canonicalize(s.trigger.conditions),
},
actions: actionHashes,
...(options.includeStepNames ? { name: s.name } : {}),
};
return hashObject(pStep);
}),
);
// 3. Aggregate design hash
return hashObject({
steps: stepHashes,
count: steps.length,
});
} }
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@@ -338,7 +370,7 @@ export async function computeIncrementalDesignHash(
order: step.order, order: step.order,
trigger: { trigger: {
type: step.trigger.type, type: step.trigger.type,
conditionKeys: Object.keys(step.trigger.conditions).sort(), conditions: canonicalize(step.trigger.conditions),
}, },
actions: step.actions.map((a) => actionHashes.get(a.id) ?? ""), actions: step.actions.map((a) => actionHashes.get(a.id) ?? ""),
...(options.includeStepNames ? { name: step.name } : {}), ...(options.includeStepNames ? { name: step.name } : {}),

View File

@@ -93,7 +93,7 @@ export interface DesignerState {
parentId: string | null; parentId: string | null;
index: number; index: number;
action: ExperimentAction; action: ExperimentAction;
} | null } | null,
) => void; ) => void;
/* ------------------------------ Mutators --------------------------------- */ /* ------------------------------ Mutators --------------------------------- */
@@ -109,10 +109,20 @@ export interface DesignerState {
reorderStep: (from: number, to: number) => void; reorderStep: (from: number, to: number) => void;
// Actions // Actions
upsertAction: (stepId: string, action: ExperimentAction, parentId?: string | null, index?: number) => void; upsertAction: (
stepId: string,
action: ExperimentAction,
parentId?: string | null,
index?: number,
) => void;
removeAction: (stepId: string, actionId: string) => void; removeAction: (stepId: string, actionId: string) => void;
reorderAction: (stepId: string, from: number, to: number) => void; reorderAction: (stepId: string, from: number, to: number) => void;
moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) => void; moveAction: (
stepId: string,
actionId: string,
newParentId: string | null,
newIndex: number,
) => void;
// Dirty // Dirty
markDirty: (id: string) => void; markDirty: (id: string) => void;
@@ -158,18 +168,22 @@ export interface DesignerState {
/* Helpers */ /* Helpers */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
function cloneActions(actions: ExperimentAction[]): ExperimentAction[] {
return actions.map((a) => ({
...a,
children: a.children ? cloneActions(a.children) : undefined,
}));
}
function cloneSteps(steps: ExperimentStep[]): ExperimentStep[] { function cloneSteps(steps: ExperimentStep[]): ExperimentStep[] {
return steps.map((s) => ({ return steps.map((s) => ({
...s, ...s,
actions: s.actions.map((a) => ({ ...a })), actions: cloneActions(s.actions),
})); }));
} }
function reindexSteps(steps: ExperimentStep[]): ExperimentStep[] { function reindexSteps(steps: ExperimentStep[]): ExperimentStep[] {
return steps return steps.map((s, idx) => ({ ...s, order: idx }));
.slice()
.sort((a, b) => a.order - b.order)
.map((s, idx) => ({ ...s, order: idx }));
} }
function reindexActions(actions: ExperimentAction[]): ExperimentAction[] { function reindexActions(actions: ExperimentAction[]): ExperimentAction[] {
@@ -250,298 +264,335 @@ function insertActionIntoTree(
/* Store Implementation */ /* Store Implementation */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
export const useDesignerStore = create<DesignerState>((set, get) => ({ export const createDesignerStore = (props: {
steps: [], initialSteps?: ExperimentStep[];
dirtyEntities: new Set<string>(), }) =>
validationIssues: {}, create<DesignerState>((set, get) => ({
actionSignatureIndex: new Map(), steps: props.initialSteps
actionSignatureDrift: new Set(), ? reindexSteps(cloneSteps(props.initialSteps))
pendingSave: false, : [],
versionStrategy: "auto_minor" as VersionStrategy, dirtyEntities: new Set<string>(),
autoSaveEnabled: true, validationIssues: {},
busyHashing: false, actionSignatureIndex: new Map(),
busyValidating: false, actionSignatureDrift: new Set(),
insertionProjection: null, pendingSave: false,
versionStrategy: "auto_minor" as VersionStrategy,
autoSaveEnabled: true,
busyHashing: false,
busyValidating: false,
insertionProjection: null,
/* ------------------------------ Selection -------------------------------- */ /* ------------------------------ Selection -------------------------------- */
selectStep: (id) => selectStep: (id) =>
set({ set({
selectedStepId: id, selectedStepId: id,
selectedActionId: id ? get().selectedActionId : undefined, selectedActionId: id ? get().selectedActionId : undefined,
}), }),
selectAction: (stepId, actionId) => selectAction: (stepId, actionId) =>
set({ set({
selectedStepId: stepId, selectedStepId: stepId,
selectedActionId: actionId, selectedActionId: actionId,
}), }),
/* -------------------------------- Steps ---------------------------------- */ /* -------------------------------- Steps ---------------------------------- */
setSteps: (steps) => setSteps: (steps) =>
set(() => ({ set(() => ({
steps: reindexSteps(cloneSteps(steps)), steps: reindexSteps(cloneSteps(steps)),
dirtyEntities: new Set<string>(), // assume authoritative load dirtyEntities: new Set<string>(), // assume authoritative load
})), })),
upsertStep: (step) => upsertStep: (step) =>
set((state) => { set((state) => {
const idx = state.steps.findIndex((s) => s.id === step.id); const idx = state.steps.findIndex((s) => s.id === step.id);
let steps: ExperimentStep[]; let steps: ExperimentStep[];
if (idx >= 0) { if (idx >= 0) {
steps = [...state.steps]; steps = [...state.steps];
steps[idx] = { ...step }; steps[idx] = { ...step };
} else { } else {
steps = [...state.steps, { ...step, order: state.steps.length }]; steps = [...state.steps, { ...step, order: state.steps.length }];
} }
return { return {
steps: reindexSteps(steps), steps: reindexSteps(steps),
dirtyEntities: new Set([...state.dirtyEntities, step.id]), dirtyEntities: new Set([...state.dirtyEntities, step.id]),
}; };
}), }),
removeStep: (stepId) => removeStep: (stepId) =>
set((state) => { set((state) => {
const steps = state.steps.filter((s) => s.id !== stepId); const steps = state.steps.filter((s) => s.id !== stepId);
const dirty = new Set(state.dirtyEntities); const dirty = new Set(state.dirtyEntities);
dirty.add(stepId); dirty.add(stepId);
return { return {
steps: reindexSteps(steps), steps: reindexSteps(steps),
dirtyEntities: dirty, dirtyEntities: dirty,
selectedStepId: selectedStepId:
state.selectedStepId === stepId ? undefined : state.selectedStepId, state.selectedStepId === stepId ? undefined : state.selectedStepId,
selectedActionId: undefined, selectedActionId: undefined,
}; };
}), }),
reorderStep: (from: number, to: number) => reorderStep: (from: number, to: number) =>
set((state: DesignerState) => { set((state: DesignerState) => {
if ( if (
from < 0 || from < 0 ||
to < 0 || to < 0 ||
from >= state.steps.length || from >= state.steps.length ||
to >= state.steps.length || to >= state.steps.length ||
from === to from === to
) { ) {
return state; return state;
} }
const stepsDraft = [...state.steps]; const stepsDraft = [...state.steps];
const [moved] = stepsDraft.splice(from, 1); const [moved] = stepsDraft.splice(from, 1);
if (!moved) return state; if (!moved) return state;
stepsDraft.splice(to, 0, moved); stepsDraft.splice(to, 0, moved);
const reindexed = reindexSteps(stepsDraft); const reindexed = reindexSteps(stepsDraft);
return { return {
steps: reindexed, steps: reindexed,
dirtyEntities: new Set<string>([ dirtyEntities: new Set<string>([
...state.dirtyEntities, ...state.dirtyEntities,
...reindexed.map((s) => s.id), ...reindexed.map((s) => s.id),
]), ]),
}; };
}), }),
/* ------------------------------- Actions --------------------------------- */ /* ------------------------------- Actions --------------------------------- */
upsertAction: (stepId: string, action: ExperimentAction, parentId: string | null = null, index?: number) => upsertAction: (
set((state: DesignerState) => { stepId: string,
const stepsDraft: ExperimentStep[] = state.steps.map((s) => { action: ExperimentAction,
if (s.id !== stepId) return s; parentId: string | null = null,
index?: number,
) =>
set((state: DesignerState) => {
const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
if (s.id !== stepId) return s;
// Check if exists (update)
const exists = findActionById(s.actions, action.id);
if (exists) {
// If updating, we don't (currently) support moving via upsert.
// Use moveAction for moving.
return {
...s,
actions: updateActionInTree(s.actions, action),
};
}
// Add new
// If index is provided, use it. Otherwise append.
const insertIndex = index ?? s.actions.length;
// Check if exists (update)
const exists = findActionById(s.actions, action.id);
if (exists) {
// If updating, we don't (currently) support moving via upsert.
// Use moveAction for moving.
return { return {
...s, ...s,
actions: updateActionInTree(s.actions, action) actions: insertActionIntoTree(
s.actions,
action,
parentId,
insertIndex,
),
}; };
} });
// Add new
// If index is provided, use it. Otherwise append.
const insertIndex = index ?? s.actions.length;
return { return {
...s, steps: stepsDraft,
actions: insertActionIntoTree(s.actions, action, parentId, insertIndex) dirtyEntities: new Set<string>([
...state.dirtyEntities,
action.id,
stepId,
]),
}; };
}); }),
return {
steps: stepsDraft,
dirtyEntities: new Set<string>([
...state.dirtyEntities,
action.id,
stepId,
]),
};
}),
removeAction: (stepId: string, actionId: string) => removeAction: (stepId: string, actionId: string) =>
set((state: DesignerState) => { set((state: DesignerState) => {
const stepsDraft: ExperimentStep[] = state.steps.map((s) => const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
s.id === stepId s.id === stepId
? { ? {
...s, ...s,
actions: removeActionFromTree(s.actions, actionId), actions: removeActionFromTree(s.actions, actionId),
} }
: s, : s,
); );
const dirty = new Set<string>(state.dirtyEntities); const dirty = new Set<string>(state.dirtyEntities);
dirty.add(actionId); dirty.add(actionId);
dirty.add(stepId); dirty.add(stepId);
return { return {
steps: stepsDraft, steps: stepsDraft,
dirtyEntities: dirty, dirtyEntities: dirty,
selectedActionId: selectedActionId:
state.selectedActionId === actionId state.selectedActionId === actionId
? undefined ? undefined
: state.selectedActionId, : state.selectedActionId,
}; };
}), }),
moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) => moveAction: (
set((state: DesignerState) => { stepId: string,
const stepsDraft = state.steps.map((s) => { actionId: string,
if (s.id !== stepId) return s; newParentId: string | null,
newIndex: number,
) =>
set((state: DesignerState) => {
const stepsDraft = state.steps.map((s) => {
if (s.id !== stepId) return s;
const actionToMove = findActionById(s.actions, actionId); const actionToMove = findActionById(s.actions, actionId);
if (!actionToMove) return s; if (!actionToMove) return s;
const pruned = removeActionFromTree(s.actions, actionId); const pruned = removeActionFromTree(s.actions, actionId);
const inserted = insertActionIntoTree(pruned, actionToMove, newParentId, newIndex); const inserted = insertActionIntoTree(
return { ...s, actions: inserted }; pruned,
}); actionToMove,
return { newParentId,
steps: stepsDraft, newIndex,
dirtyEntities: new Set<string>([...state.dirtyEntities, stepId, actionId]), );
}; return { ...s, actions: inserted };
}), });
return {
steps: stepsDraft,
dirtyEntities: new Set<string>([
...state.dirtyEntities,
stepId,
actionId,
]),
};
}),
reorderAction: (stepId: string, from: number, to: number) => reorderAction: (stepId: string, from: number, to: number) =>
get().moveAction(stepId, get().steps.find(s => s.id === stepId)?.actions[from]?.id!, null, to), // Legacy compat support (only works for root level reorder) get().moveAction(
stepId,
get().steps.find((s) => s.id === stepId)?.actions[from]?.id!,
null,
to,
), // Legacy compat support (only works for root level reorder)
setInsertionProjection: (projection) => set({ insertionProjection: projection }), setInsertionProjection: (projection) =>
set({ insertionProjection: projection }),
/* -------------------------------- Dirty ---------------------------------- */ /* -------------------------------- Dirty ---------------------------------- */
markDirty: (id: string) => markDirty: (id: string) =>
set((state: DesignerState) => ({ set((state: DesignerState) => ({
dirtyEntities: state.dirtyEntities.has(id) dirtyEntities: state.dirtyEntities.has(id)
? state.dirtyEntities ? state.dirtyEntities
: new Set<string>([...state.dirtyEntities, id]), : new Set<string>([...state.dirtyEntities, id]),
})), })),
clearDirty: (id: string) => clearDirty: (id: string) =>
set((state: DesignerState) => { set((state: DesignerState) => {
if (!state.dirtyEntities.has(id)) return state; if (!state.dirtyEntities.has(id)) return state;
const next = new Set(state.dirtyEntities); const next = new Set(state.dirtyEntities);
next.delete(id); next.delete(id);
return { dirtyEntities: next }; return { dirtyEntities: next };
}), }),
clearAllDirty: () => set({ dirtyEntities: new Set<string>() }), clearAllDirty: () => set({ dirtyEntities: new Set<string>() }),
/* ------------------------------- Hashing --------------------------------- */ /* ------------------------------- Hashing --------------------------------- */
recomputeHash: async (options?: { forceFull?: boolean }) => { recomputeHash: async (options?: { forceFull?: boolean }) => {
const { steps, incremental } = get(); const { steps, incremental } = get();
if (steps.length === 0) { if (steps.length === 0) {
set({ currentDesignHash: undefined }); set({ currentDesignHash: undefined });
return null; return null;
}
set({ busyHashing: true });
try {
const result = await computeIncrementalDesignHash(
steps,
options?.forceFull ? undefined : incremental,
);
set({
currentDesignHash: result.designHash,
incremental: {
actionHashes: result.actionHashes,
stepHashes: result.stepHashes,
},
});
return result;
} finally {
set({ busyHashing: false });
}
},
setPersistedHash: (hash: string) => set({ lastPersistedHash: hash }),
setValidatedHash: (hash: string) => set({ lastValidatedHash: hash }),
/* ----------------------------- Validation -------------------------------- */
setValidationIssues: (entityId: string, issues: ValidationIssue[]) =>
set((state: DesignerState) => ({
validationIssues: {
...state.validationIssues,
[entityId]: issues,
},
})),
clearValidationIssues: (entityId: string) =>
set((state: DesignerState) => {
if (!state.validationIssues[entityId]) return state;
const next = { ...state.validationIssues };
delete next[entityId];
return { validationIssues: next };
}),
clearAllValidationIssues: () => set({ validationIssues: {} }),
/* ------------------------- Action Signature Drift ------------------------ */
setActionSignature: (actionId: string, signature: string) =>
set((state: DesignerState) => {
const index = new Map(state.actionSignatureIndex);
index.set(actionId, signature);
return { actionSignatureIndex: index };
}),
detectActionSignatureDrift: (
action: ExperimentAction,
latestSignature: string,
) =>
set((state: DesignerState) => {
const current = state.actionSignatureIndex.get(action.id);
if (!current) {
const idx = new Map(state.actionSignatureIndex);
idx.set(action.id, latestSignature);
return { actionSignatureIndex: idx };
} }
if (current === latestSignature) return {}; set({ busyHashing: true });
const drift = new Set(state.actionSignatureDrift); try {
drift.add(action.id); const result = await computeIncrementalDesignHash(
return { actionSignatureDrift: drift }; steps,
}), options?.forceFull ? undefined : incremental,
clearActionSignatureDrift: (actionId: string) => );
set((state: DesignerState) => { set({
if (!state.actionSignatureDrift.has(actionId)) return state; currentDesignHash: result.designHash,
const next = new Set(state.actionSignatureDrift); incremental: {
next.delete(actionId); actionHashes: result.actionHashes,
return { actionSignatureDrift: next }; stepHashes: result.stepHashes,
}), },
});
return result;
} finally {
set({ busyHashing: false });
}
},
/* ------------------------------- Save Flow -------------------------------- */ setPersistedHash: (hash: string) => set({ lastPersistedHash: hash }),
setPendingSave: (pending: boolean) => set({ pendingSave: pending }), setValidatedHash: (hash: string) => set({ lastValidatedHash: hash }),
recordConflict: (serverHash: string, localHash: string) =>
set({
conflict: { serverHash, localHash, at: new Date() },
pendingSave: false,
}),
clearConflict: () => set({ conflict: undefined }),
setVersionStrategy: (strategy: VersionStrategy) =>
set({ versionStrategy: strategy }),
setAutoSaveEnabled: (enabled: boolean) => set({ autoSaveEnabled: enabled }),
/* ------------------------------ Server Sync ------------------------------ */ /* ----------------------------- Validation -------------------------------- */
applyServerSync: (payload: { setValidationIssues: (entityId: string, issues: ValidationIssue[]) =>
steps: ExperimentStep[]; set((state: DesignerState) => ({
persistedHash?: string; validationIssues: {
validatedHash?: string; ...state.validationIssues,
}) => [entityId]: issues,
set((state: DesignerState) => { },
const syncedSteps = reindexSteps(cloneSteps(payload.steps)); })),
const dirty = new Set<string>(); clearValidationIssues: (entityId: string) =>
return { set((state: DesignerState) => {
steps: syncedSteps, if (!state.validationIssues[entityId]) return state;
lastPersistedHash: payload.persistedHash ?? state.lastPersistedHash, const next = { ...state.validationIssues };
lastValidatedHash: payload.validatedHash ?? state.lastValidatedHash, delete next[entityId];
dirtyEntities: dirty, return { validationIssues: next };
conflict: undefined, }),
}; clearAllValidationIssues: () => set({ validationIssues: {} }),
}),
})); /* ------------------------- Action Signature Drift ------------------------ */
setActionSignature: (actionId: string, signature: string) =>
set((state: DesignerState) => {
const index = new Map(state.actionSignatureIndex);
index.set(actionId, signature);
return { actionSignatureIndex: index };
}),
detectActionSignatureDrift: (
action: ExperimentAction,
latestSignature: string,
) =>
set((state: DesignerState) => {
const current = state.actionSignatureIndex.get(action.id);
if (!current) {
const idx = new Map(state.actionSignatureIndex);
idx.set(action.id, latestSignature);
return { actionSignatureIndex: idx };
}
if (current === latestSignature) return {};
const drift = new Set(state.actionSignatureDrift);
drift.add(action.id);
return { actionSignatureDrift: drift };
}),
clearActionSignatureDrift: (actionId: string) =>
set((state: DesignerState) => {
if (!state.actionSignatureDrift.has(actionId)) return state;
const next = new Set(state.actionSignatureDrift);
next.delete(actionId);
return { actionSignatureDrift: next };
}),
/* ------------------------------- Save Flow -------------------------------- */
setPendingSave: (pending: boolean) => set({ pendingSave: pending }),
recordConflict: (serverHash: string, localHash: string) =>
set({
conflict: { serverHash, localHash, at: new Date() },
pendingSave: false,
}),
clearConflict: () => set({ conflict: undefined }),
setVersionStrategy: (strategy: VersionStrategy) =>
set({ versionStrategy: strategy }),
setAutoSaveEnabled: (enabled: boolean) => set({ autoSaveEnabled: enabled }),
/* ------------------------------ Server Sync ------------------------------ */
applyServerSync: (payload: {
steps: ExperimentStep[];
persistedHash?: string;
validatedHash?: string;
}) =>
set((state: DesignerState) => {
const syncedSteps = reindexSteps(cloneSteps(payload.steps));
const dirty = new Set<string>();
return {
steps: syncedSteps,
lastPersistedHash: payload.persistedHash ?? state.lastPersistedHash,
lastValidatedHash: payload.validatedHash ?? state.lastValidatedHash,
dirtyEntities: dirty,
conflict: undefined,
};
}),
}));
export const useDesignerStore = createDesignerStore({});
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Convenience Selectors */ /* Convenience Selectors */

View File

@@ -49,12 +49,9 @@ export interface ValidationResult {
/* Validation Rule Sets */ /* Validation Rule Sets */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
const VALID_STEP_TYPES: StepType[] = [ // Steps should ALWAYS execute sequentially
"sequential", // Parallel/conditional/loop execution happens at the ACTION level, not step level
"parallel", const VALID_STEP_TYPES: StepType[] = ["sequential", "conditional"];
"conditional",
"loop",
];
const VALID_TRIGGER_TYPES: TriggerType[] = [ const VALID_TRIGGER_TYPES: TriggerType[] = [
"trial_start", "trial_start",
"participant_action", "participant_action",
@@ -144,48 +141,8 @@ export function validateStructural(
}); });
} }
// Conditional step must have conditions // All steps must be sequential type (parallel/conditional/loop removed)
if (step.type === "conditional") { // Control flow and parallelism should be implemented at the ACTION level
const conditionKeys = Object.keys(step.trigger.conditions || {});
if (conditionKeys.length === 0) {
issues.push({
severity: "error",
message: "Conditional step must define at least one condition",
category: "structural",
field: "trigger.conditions",
stepId,
suggestion: "Add conditions to define when this step should execute",
});
}
}
// Loop step should have termination conditions
if (step.type === "loop") {
const conditionKeys = Object.keys(step.trigger.conditions || {});
if (conditionKeys.length === 0) {
issues.push({
severity: "warning",
message:
"Loop step should define termination conditions to prevent infinite loops",
category: "structural",
field: "trigger.conditions",
stepId,
suggestion: "Add conditions to control when the loop should exit",
});
}
}
// Parallel step should have multiple actions
if (step.type === "parallel" && step.actions.length < 2) {
issues.push({
severity: "warning",
message:
"Parallel step has fewer than 2 actions - consider using sequential type",
category: "structural",
stepId,
suggestion: "Add more actions or change to sequential execution",
});
}
// Action-level structural validation // Action-level structural validation
step.actions.forEach((action) => { step.actions.forEach((action) => {
@@ -234,6 +191,7 @@ export function validateStructural(
} }
// Plugin actions need plugin metadata // Plugin actions need plugin metadata
/* VALIDATION DISABLED BY USER REQUEST
if (action.source?.kind === "plugin") { if (action.source?.kind === "plugin") {
if (!action.source.pluginId) { if (!action.source.pluginId) {
issues.push({ issues.push({
@@ -258,6 +216,7 @@ export function validateStructural(
}); });
} }
} }
*/
// Execution descriptor validation // Execution descriptor validation
if (!action.execution?.transport) { if (!action.execution?.transport) {
@@ -430,6 +389,34 @@ export function validateParameters(
} }
break; break;
case "array":
if (!Array.isArray(value)) {
issues.push({
severity: "error",
message: `Parameter '${paramDef.name}' must be a list/array`,
category: "parameter",
field,
stepId,
actionId,
suggestion: "Enter a list of values",
});
}
break;
case "json":
if (typeof value !== "object" || value === null) {
issues.push({
severity: "error",
message: `Parameter '${paramDef.name}' must be a valid object`,
category: "parameter",
field,
stepId,
actionId,
suggestion: "Enter a valid JSON object",
});
}
break;
default: default:
// Unknown parameter type // Unknown parameter type
issues.push({ issues.push({
@@ -532,10 +519,9 @@ export function validateSemantic(
// Check for empty steps // Check for empty steps
steps.forEach((step) => { steps.forEach((step) => {
if (step.actions.length === 0) { if (step.actions.length === 0) {
const severity = step.type === "parallel" ? "error" : "warning";
issues.push({ issues.push({
severity, severity: "warning",
message: `${step.type} step has no actions`, message: "Step has no actions",
category: "semantic", category: "semantic",
stepId: step.id, stepId: step.id,
suggestion: "Add actions to this step or remove it", suggestion: "Add actions to this step or remove it",
@@ -635,25 +621,9 @@ export function validateExecution(
): ValidationIssue[] { ): ValidationIssue[] {
const issues: ValidationIssue[] = []; const issues: ValidationIssue[] = [];
// Check for unreachable steps (basic heuristic) // Note: Trigger validation removed - convertDatabaseToSteps() automatically assigns
if (steps.length > 1) { // correct triggers (trial_start for first step, previous_step for others) based on orderIndex.
const trialStartSteps = steps.filter( // Manual trigger configuration is intentional for advanced workflows.
(s) => s.trigger.type === "trial_start",
);
if (trialStartSteps.length > 1) {
trialStartSteps.slice(1).forEach((step) => {
issues.push({
severity: "info",
message:
"This step will start immediately at trial start. For sequential flow, use 'Previous Step' trigger.",
category: "execution",
field: "trigger.type",
stepId: step.id,
suggestion: "Change trigger to 'Previous Step' if this step should follow the previous one",
});
});
}
}
// Check for missing robot dependencies // Check for missing robot dependencies
const robotActions = steps.flatMap((step) => const robotActions = steps.flatMap((step) =>

View File

@@ -0,0 +1,343 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "~/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { toast } from "sonner";
import { api } from "~/trpc/react";
import { experimentStatusEnum } from "~/server/db/schema";
import { Save, ExternalLink } from "lucide-react";
import Link from "next/link";
const formSchema = z.object({
name: z.string().min(2, {
message: "Name must be at least 2 characters.",
}),
description: z.string().optional(),
status: z.enum(experimentStatusEnum.enumValues),
});
interface SettingsTabProps {
experiment: {
id: string;
name: string;
description: string | null;
status: string;
studyId: string;
createdAt: Date;
updatedAt: Date;
study: {
id: string;
name: string;
};
};
designStats?: {
stepCount: number;
actionCount: number;
};
}
export function SettingsTab({ experiment, designStats }: SettingsTabProps) {
const utils = api.useUtils();
const updateExperiment = api.experiments.update.useMutation({
onSuccess: async () => {
toast.success("Experiment settings saved successfully");
// Invalidate experiments list to refresh data
await utils.experiments.list.invalidate();
},
onError: (error) => {
toast.error(`Error saving settings: ${error.message}`);
},
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: experiment.name,
description: experiment.description ?? "",
status: experiment.status as z.infer<typeof formSchema>["status"],
},
});
function onSubmit(values: z.infer<typeof formSchema>) {
updateExperiment.mutate({
id: experiment.id,
name: values.name,
description: values.description,
status: values.status,
});
}
const isDirty = form.formState.isDirty;
return (
<div className="h-full overflow-y-auto p-6">
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-2xl font-bold tracking-tight">
Experiment Settings
</h2>
<p className="text-muted-foreground mt-1">
Configure experiment metadata and status
</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{/* Left Column: Basic Information (Spans 2) */}
<div className="space-y-6 md:col-span-2">
<Card className="h-full">
<CardHeader>
<CardTitle>Basic Information</CardTitle>
<CardDescription>
The name and description help identify this experiment
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Experiment name" {...field} />
</FormControl>
<FormDescription>
A clear, descriptive name for your experiment
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Describe your experiment goals, methodology, and expected outcomes..."
className="min-h-[300px] resize-none"
{...field}
/>
</FormControl>
<FormDescription>
Detailed description of the experiment purpose and
design
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</div>
{/* Right Column: Status & Metadata (Spans 1) */}
<div className="space-y-6">
{/* Status Card */}
<Card>
<CardHeader>
<CardTitle>Status</CardTitle>
<CardDescription>Track lifecycle stage</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Current Status</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="draft">
<div className="flex items-center gap-2">
<Badge variant="secondary">Draft</Badge>
<span className="text-muted-foreground text-xs">
WIP
</span>
</div>
</SelectItem>
<SelectItem value="testing">
<div className="flex items-center gap-2">
<Badge variant="outline">Testing</Badge>
<span className="text-muted-foreground text-xs">
Validation
</span>
</div>
</SelectItem>
<SelectItem value="ready">
<div className="flex items-center gap-2">
<Badge
variant="default"
className="bg-green-500"
>
Ready
</Badge>
<span className="text-muted-foreground text-xs">
Live
</span>
</div>
</SelectItem>
<SelectItem value="deprecated">
<div className="flex items-center gap-2">
<Badge variant="destructive">
Deprecated
</Badge>
<span className="text-muted-foreground text-xs">
Retired
</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Metadata Card */}
<Card>
<CardHeader>
<CardTitle>Metadata</CardTitle>
<CardDescription>Read-only information</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div>
<p className="text-muted-foreground mb-1 text-xs font-medium">
Study
</p>
<Link
href={`/studies/${experiment.study.id}`}
className="text-primary flex items-center gap-1 truncate text-sm hover:underline"
>
{experiment.study.name}
<ExternalLink className="h-3 w-3 flex-shrink-0" />
</Link>
</div>
<div>
<p className="text-muted-foreground mb-1 text-xs font-medium">
Experiment ID
</p>
<p className="bg-muted rounded p-1 font-mono text-xs select-all">
{experiment.id.split("-")[0]}...
</p>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<p className="text-muted-foreground mb-1 text-xs font-medium">
Created
</p>
<p className="text-xs">
{new Date(
experiment.createdAt,
).toLocaleDateString()}
</p>
</div>
<div>
<p className="text-muted-foreground mb-1 text-xs font-medium">
Updated
</p>
<p className="text-xs">
{new Date(
experiment.updatedAt,
).toLocaleDateString()}
</p>
</div>
</div>
</div>
{designStats && (
<div className="border-t pt-4">
<p className="text-muted-foreground mb-2 text-xs font-medium">
Statistics
</p>
<div className="flex flex-wrap gap-2">
<div className="bg-muted/50 flex items-center gap-1.5 rounded px-2 py-1 text-xs">
<span className="font-semibold">
{designStats.stepCount}
</span>
<span className="text-muted-foreground">Steps</span>
</div>
<div className="bg-muted/50 flex items-center gap-1.5 rounded px-2 py-1 text-xs">
<span className="font-semibold">
{designStats.actionCount}
</span>
<span className="text-muted-foreground">
Actions
</span>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end border-t pt-4">
<Button
type="submit"
disabled={updateExperiment.isPending || !isDirty}
className="min-w-[120px]"
>
{updateExperiment.isPending ? (
"Saving..."
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save Changes
</>
)}
</Button>
</div>
</form>
</Form>
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import {
Edit, Edit,
Eye, Eye,
FlaskConical, FlaskConical,
LayoutTemplate,
MoreHorizontal, MoreHorizontal,
Play, Play,
TestTube, TestTube,
@@ -27,6 +28,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { api } from "~/trpc/react";
export type Experiment = { export type Experiment = {
id: string; id: string;
@@ -78,92 +80,55 @@ const statusConfig = {
}; };
function ExperimentActionsCell({ experiment }: { experiment: Experiment }) { function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
const handleDelete = async () => { const utils = api.useUtils();
const deleteMutation = api.experiments.delete.useMutation({
onSuccess: () => {
toast.success("Experiment deleted successfully");
utils.experiments.list.invalidate();
},
onError: (error) => {
toast.error(`Failed to delete experiment: ${error.message}`);
},
});
const handleDelete = () => {
if ( if (
window.confirm(`Are you sure you want to delete "${experiment.name}"?`) window.confirm(`Are you sure you want to delete "${experiment.name}"?`)
) { ) {
try { deleteMutation.mutate({ id: experiment.id });
// TODO: Implement delete experiment mutation
toast.success("Experiment deleted successfully");
} catch {
toast.error("Failed to delete experiment");
}
} }
}; };
const handleCopyId = () => {
void navigator.clipboard.writeText(experiment.id);
toast.success("Experiment ID copied to clipboard");
};
const handleStartTrial = () => {
// Navigate to new trial creation with this experiment pre-selected
window.location.href = `/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`;
};
return ( return (
<DropdownMenu> <div className="flex items-center gap-2">
<DropdownMenuTrigger asChild> <Button
<Button variant="ghost" className="h-8 w-8 p-0"> variant="ghost"
<span className="sr-only">Open menu</span> size="icon"
<MoreHorizontal className="h-4 w-4" /> asChild
className="text-muted-foreground hover:text-primary h-8 w-8"
title="Open Designer"
>
<Link
href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}
>
<LayoutTemplate className="h-4 w-4" />
<span className="sr-only">Design</span>
</Link>
</Button>
{experiment.canDelete && (
<Button
variant="ghost"
size="icon"
onClick={handleDelete}
className="text-muted-foreground hover:text-destructive h-8 w-8"
title="Delete Experiment"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button> </Button>
</DropdownMenuTrigger> )}
<DropdownMenuContent align="end"> </div>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
<FlaskConical className="mr-2 h-4 w-4" />
Open Designer
</Link>
</DropdownMenuItem>
{experiment.canEdit && (
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Experiment
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{experiment.status === "ready" && (
<DropdownMenuItem onClick={handleStartTrial}>
<Play className="mr-2 h-4 w-4" />
Start New Trial
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleCopyId}>
<Copy className="mr-2 h-4 w-4" />
Copy Experiment ID
</DropdownMenuItem>
{experiment.canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleDelete}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Experiment
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
); );
} }
@@ -315,20 +280,7 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
}, },
enableSorting: false, enableSorting: false,
}, },
{
accessorKey: "createdAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Created" />
),
cell: ({ row }) => {
const date = row.getValue("createdAt");
return (
<div className="text-sm whitespace-nowrap">
{formatDistanceToNow(date as Date, { addSuffix: true })}
</div>
);
},
},
{ {
accessorKey: "updatedAt", accessorKey: "updatedAt",
header: ({ column }) => ( header: ({ column }) => (

View File

@@ -1,265 +1,423 @@
"use client"; "use client";
import React, { createContext, useContext, useEffect, useRef } from "react"; import React, {
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { driver, type Driver } from "driver.js"; import { driver, type Driver } from "driver.js";
import "driver.js/dist/driver.css"; import "driver.js/dist/driver.css";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { usePathname } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
type TourType = "dashboard" | "study_creation" | "designer" | "wizard" | "full_platform"; type TourType =
| "dashboard"
| "study_creation"
| "participant_creation"
| "designer"
| "wizard"
| "analytics"
| "full_platform";
interface TourContextType { interface TourContextType {
startTour: (tour: TourType) => void; startTour: (tour: TourType) => void;
isTourActive: boolean;
} }
const TourContext = createContext<TourContextType | undefined>(undefined); const TourContext = createContext<TourContextType | undefined>(undefined);
export function useTour() { export function useTour() {
const context = useContext(TourContext); const context = useContext(TourContext);
if (!context) { if (!context) {
throw new Error("useTour must be used within a TourProvider"); throw new Error("useTour must be used within a TourProvider");
} }
return context; return context;
} }
export function TourProvider({ children }: { children: React.ReactNode }) { export function TourProvider({ children }: { children: React.ReactNode }) {
const driverObj = useRef<Driver | null>(null); const driverObj = useRef<Driver | null>(null);
const { theme } = useTheme(); const [isTourActive, setIsTourActive] = useState(false);
const pathname = usePathname(); const { theme } = useTheme();
const pathname = usePathname();
const router = useRouter();
// --- Multi-page Tour Logic --- // --- Multi-page Tour Logic ---
useEffect(() => { useEffect(() => {
// Check if we are in "Full Platform" mode (Local Storage OR Cookie) // Check if we are in "Full Platform" mode (Local Storage OR Cookie)
const localMode = localStorage.getItem("hristudio_tour_mode"); const localMode = localStorage.getItem("hristudio_tour_mode");
const cookieMode = Cookies.get("hristudio_tour_mode"); const cookieMode = Cookies.get("hristudio_tour_mode");
const tourMode = localMode === "full" || cookieMode === "full" ? "full" : null; const tourMode =
localMode === "full" || cookieMode === "full" ? "full" : null;
if (tourMode === "full") { if (tourMode === "full") {
// Re-sync local storage if missing but cookie present // Re-sync local storage if missing but cookie present
if (localMode !== "full") localStorage.setItem("hristudio_tour_mode", "full"); if (localMode !== "full")
localStorage.setItem("hristudio_tour_mode", "full");
// Small delay to ensure DOM is ready // Small delay to ensure DOM is ready
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (pathname === "/dashboard") { if (pathname === "/dashboard") {
runTourSegment("dashboard"); runTourSegment("dashboard");
} else if (pathname.includes("/studies/new")) { } else if (pathname.includes("/studies/new")) {
runTourSegment("study_creation"); runTourSegment("study_creation");
} else if (pathname.includes("/designer")) { } else if (pathname.includes("/participants/new")) {
runTourSegment("designer"); runTourSegment("participant_creation");
} else if (pathname.includes("/wizard")) { } else if (pathname.includes("/designer")) {
runTourSegment("wizard"); runTourSegment("designer");
} } else if (pathname.includes("/wizard")) {
}, 500); // Reduced delay for snappier feel, but still safe for render runTourSegment("wizard");
return () => clearTimeout(timer);
} }
}, [pathname]); }, 500); // Reduced delay for snappier feel, but still safe for render
return () => clearTimeout(timer);
}
}, [pathname]);
const runTourSegment = (segment: "dashboard" | "study_creation" | "designer" | "wizard") => { useEffect(() => {
const isDark = theme === "dark"; // Listen for custom tour triggers (from components without context access)
// We add a specific class to handle dark/light overrides reliably const handleTourTrigger = (e: Event) => {
const themeClass = isDark ? "driverjs-theme-dark" : "driverjs-theme-light"; const detail = (e as CustomEvent).detail as TourType;
if (detail) {
let steps: any[] = []; startTour(detail);
}
if (segment === "dashboard") {
steps = [
{
element: "#dashboard-header",
popover: {
title: "Overview",
description: "Welcome to HRIStudio. This dashboard gives you a high-level view of your research activities, active studies, and data collection progress.",
side: "bottom",
align: "start",
},
},
{
element: "#tour-sidebar-overview",
popover: {
title: "Navigation: Overview",
description: "Quickly return to this main dashboard from anywhere in the application.",
side: "right",
},
},
{
element: "#tour-sidebar-studies",
popover: {
title: "Navigation: Studies",
description: "Manage all your research studies, IRBs, and team permissions in one place.",
side: "right",
},
},
{
element: "#tour-sidebar-study-selector",
popover: {
title: "Active Study Selector",
description: "Switch between different studies here. Selecting a study unlocks study-specific tools like the Experiment Designer and Data Analytics.",
side: "right",
},
},
{
element: "#tour-new-study",
popover: {
title: "Create a New Study",
description: "Ready to start? Click here to initialize a new research project and define your protocol.",
side: "right",
},
},
];
} else if (segment === "study_creation") {
steps = [
{
element: "#tour-study-name",
popover: {
title: "Naming Your Study",
description: "Choose a concise, descriptive name. This will properly namespace your data, logs, and robot configurations.",
side: "right",
}
},
{
element: "#tour-study-description",
popover: {
title: "Research Protocol",
description: "Add a short description of your methodology or research questions. This helps team members understand the context.",
side: "right",
}
},
{
element: "#tour-study-submit",
popover: {
title: "Initialize Project",
description: "Create the study to access the full suite of tools: Experiment Designer, Wizard Interface, and Analytics.",
side: "top",
}
}
];
} else if (segment === "designer") {
steps = [
{
element: "#tour-designer-blocks",
popover: {
title: "Action Library",
description: "Drag and drop robot behaviors (Speech, Gestures, Movement) onto the canvas. Includes both core actions and those from installed plugins.",
side: "right",
},
},
{
element: "#tour-designer-canvas",
popover: {
title: "Visual Flow Canvas",
description: "Design your experiment logic here. Connect blocks to create sequences, branches, and loops for the robot to execute.",
side: "top",
},
},
{
element: "#tour-designer-properties",
popover: {
title: "Properties Panel",
description: "Select any block to configure its parameters—like speech text, speed, volume, or timeout durations.",
side: "left",
},
},
];
} else if (segment === "wizard") {
steps = [
{
element: "#tour-wizard-controls",
popover: {
title: "Wizard Dashboard",
description: "The command center for running trials. Manually trigger robot actions or override autonomous behaviors in real-time.",
side: "right",
},
},
{
element: "#tour-wizard-timeline",
popover: {
title: "Live Timeline",
description: "See exactly what the robot is doing, what's coming next, and a history of all events in the current session.",
side: "top",
},
},
{
element: "#tour-wizard-robot-status",
popover: {
title: "System Health",
description: "Monitor critical telemetry: battery levels, joint temperatures, and network latency to ensure safety.",
side: "left",
},
},
];
}
driverObj.current = driver({
showProgress: true,
animate: true,
allowClose: true,
steps: steps.map((step) => ({
...step,
popover: {
...step.popover,
popoverClass: `driver-popover-override ${themeClass}`,
},
})),
onDestroyed: () => {
// Persistence handled by localStorage state
}
});
driverObj.current.drive();
}; };
const startTour = (tour: TourType) => { document.addEventListener("hristudio-start-tour", handleTourTrigger);
if (tour === "full_platform") { return () =>
localStorage.setItem("hristudio_tour_mode", "full"); document.removeEventListener("hristudio-start-tour", handleTourTrigger);
Cookies.set("hristudio_tour_mode", "full", { expires: 7 }); // 7 days persistence }, []);
// Trigger current page immediately const runTourSegment = (
if (pathname === "/dashboard") runTourSegment("dashboard"); segment:
else if (pathname.includes("/studies/new")) runTourSegment("study_creation"); | "dashboard"
else if (pathname.includes("/designer")) runTourSegment("designer"); | "study_creation"
else if (pathname.includes("/wizard")) runTourSegment("wizard"); | "participant_creation"
else runTourSegment("dashboard"); // Fallback | "designer"
} else { | "wizard"
localStorage.setItem("hristudio_tour_mode", "manual"); | "analytics",
Cookies.remove("hristudio_tour_mode"); ) => {
const isDark = theme === "dark";
// We add a specific class to handle dark/light overrides reliably
const themeClass = isDark ? "driverjs-theme-dark" : "driverjs-theme-light";
if (tour === "dashboard") runTourSegment("dashboard"); let steps: any[] = [];
if (tour === "study_creation") runTourSegment("study_creation");
if (tour === "designer") runTourSegment("designer");
if (tour === "wizard") runTourSegment("wizard");
}
};
return ( if (segment === "dashboard") {
<TourContext.Provider value={{ startTour }}> steps = [
{children} {
<style jsx global>{` element: "#dashboard-header",
popover: {
title: "Overview",
description:
"Welcome to HRIStudio. This dashboard gives you a high-level view of your research activities, active studies, and data collection progress.",
side: "bottom",
align: "start",
},
},
{
element: "#tour-sidebar-overview",
popover: {
title: "Navigation: Overview",
description:
"Quickly return to this main dashboard from anywhere in the application.",
side: "right",
},
},
{
element: "#tour-sidebar-studies",
popover: {
title: "Navigation: Studies",
description:
"Manage all your research studies, IRBs, and team permissions in one place.",
side: "right",
},
},
{
element: "#tour-sidebar-study-selector",
popover: {
title: "Active Study Selector",
description:
"Switch between different studies here. Selecting a study unlocks study-specific tools like the Experiment Designer and Data Analytics.",
side: "right",
},
},
{
element: "#tour-new-study",
popover: {
title: "Create a New Study",
description:
"Ready to start? Click here to initialize a new research project and define your protocol.",
side: "right",
},
},
];
} else if (segment === "study_creation") {
steps = [
{
element: "#tour-study-name",
popover: {
title: "Naming Your Study",
description:
"Choose a concise, descriptive name. This will properly namespace your data, logs, and robot configurations.",
side: "right",
},
},
{
element: "#tour-study-description",
popover: {
title: "Research Protocol",
description:
"Add a short description of your methodology or research questions. This helps team members understand the context.",
side: "right",
},
},
{
element: "#tour-study-submit",
popover: {
title: "Initialize Project",
description:
"Create the study to access the full suite of tools: Experiment Designer, Wizard Interface, and Analytics.",
side: "top",
},
},
];
} else if (segment === "participant_creation") {
steps = [
{
element: "#tour-participant-code",
popover: {
title: "Participant ID",
description:
"Assign a unique code (e.g., P001) to identify this participant while maintaining anonymity.",
side: "right",
},
},
{
element: "#tour-participant-name",
popover: {
title: "Name (Optional)",
description:
"You store their name for internal reference; analytics will use the ID.",
side: "right",
},
},
{
element: "#tour-participant-study-container",
popover: {
title: "Study Association",
description:
"Link this participant to a specific research study to enable data collection.",
side: "right",
},
},
{
element: "#tour-participant-consent",
popover: {
title: "Informed Consent",
description:
"Mandatory check to confirm you have obtained necessary ethical approvals and consent.",
side: "top",
},
},
{
element: "#tour-participant-submit",
popover: {
title: "Register",
description:
"Create the participant record to begin scheduling trials.",
side: "top",
},
},
];
} else if (segment === "designer") {
steps = [
{
element: "#tour-designer-blocks",
popover: {
title: "Action Library",
description:
"Drag and drop robot behaviors (Speech, Gestures, Movement) onto the canvas. Includes both core actions and those from installed plugins.",
side: "right",
},
},
{
element: "#tour-designer-canvas",
popover: {
title: "Visual Flow Canvas",
description:
"Design your experiment logic here. Connect blocks to create sequences, branches, and loops for the robot to execute.",
side: "top",
},
},
{
element: "#tour-designer-properties",
popover: {
title: "Properties Panel",
description:
"Select any block to configure its parameters—like speech text, speed, volume, or timeout durations.",
side: "left",
},
},
];
} else if (segment === "wizard") {
steps = [
{
element: "#tour-wizard-controls",
popover: {
title: "Wizard Dashboard",
description:
"The command center for running trials. Manually trigger robot actions or override autonomous behaviors in real-time.",
side: "right",
},
},
{
element: "#tour-wizard-timeline",
popover: {
title: "Live Timeline",
description:
"See exactly what the robot is doing, what's coming next, and a history of all events in the current session.",
side: "top",
},
},
{
element: "#tour-wizard-robot-status",
popover: {
title: "System Health",
description:
"Monitor critical telemetry: battery levels, joint temperatures, and network latency to ensure safety.",
side: "left",
},
},
];
} else if (segment === "analytics") {
steps = [
{
element: "#tour-analytics-table",
popover: {
title: "Study Analytics",
description:
"View aggregate data across all participant sessions. Sort and filter to identify trends or specific trials.",
side: "bottom",
},
},
{
element: "#tour-analytics-filter",
popover: {
title: "Filter Data",
description:
"Quickly find participants by ID or name using this search bar.",
side: "bottom",
},
},
{
element: "#tour-trial-metrics",
popover: {
title: "Trial Metrics",
description:
"High-level KPIs for the selected trial: Duration, Robot Actions, and Intervention counts.",
side: "bottom",
},
},
{
element: "#tour-trial-timeline",
popover: {
title: "Video & Timeline",
description:
"Watch the trial recording synced with the event timeline. Click any event to jump to that moment in the video.",
side: "right",
},
},
{
element: "#tour-trial-events",
popover: {
title: "Event Log",
description:
"A detailed, searchable log of every system event, robot action, and wizard interaction.",
side: "left",
},
},
];
}
driverObj.current = driver({
showProgress: true,
animate: true,
allowClose: true,
steps: steps.map((step) => ({
...step,
popover: {
...step.popover,
popoverClass: `driver-popover-override ${themeClass}`,
},
})),
onDestroyed: () => {
// Persistence handled by localStorage state
setIsTourActive(false);
},
});
driverObj.current.drive();
setIsTourActive(true);
};
const startTour = (tour: TourType) => {
if (tour === "full_platform") {
localStorage.setItem("hristudio_tour_mode", "full");
Cookies.set("hristudio_tour_mode", "full", { expires: 7 }); // 7 days persistence
if (pathname !== "/dashboard") {
router.push("/dashboard");
return;
}
// We are already on dashboard, trigger it immediately
runTourSegment("dashboard");
} else {
localStorage.setItem("hristudio_tour_mode", "manual");
Cookies.remove("hristudio_tour_mode");
if (tour === "dashboard") runTourSegment("dashboard");
if (tour === "study_creation") runTourSegment("study_creation");
if (tour === "participant_creation")
runTourSegment("participant_creation");
if (tour === "designer") runTourSegment("designer");
if (tour === "wizard") runTourSegment("wizard");
if (tour === "analytics") runTourSegment("analytics");
}
};
return (
<TourContext.Provider value={{ startTour, isTourActive }}>
{children}
<style jsx global>{`
/* /*
SHADCN/UI THEMING OVERRIDES SHADCN/UI THEMING OVERRIDES
CRITICAL: The global variables in globals.css use OKLCH/HSL values directly or with units. CRITICAL: The global variables in globals.css use OKLCH/HSL values directly or with units.
DO NOT wrap variables in hsl() if they are already defined as colors. DO NOT wrap variables in hsl() if they are already defined as colors.
Use direct assignment. Use direct assignment.
*/ */
.driver-popover-override {
padding: 1.25rem !important;
border-radius: var(--radius) !important;
box-shadow: var(--shadow-xl) !important;
max-width: 420px !important;
/* Background & Text - Match Card Aesthetic */
background-color: var(--card) !important;
color: var(--card-foreground) !important;
border: 1px solid var(--border) !important;
/* Typography */ .driver-popover-override {
font-family: var(--font-sans) !important; padding: 1.25rem !important;
border-radius: var(--radius) !important;
box-shadow: var(--shadow-xl) !important;
max-width: 420px !important;
/* Background & Text - Match Card Aesthetic */
background-color: var(--card) !important;
color: var(--card-foreground) !important;
border: 1px solid var(--border) !important;
/* Typography */
font-family: var(--font-sans) !important;
} }
/* Arrow Styling - Critical for transparent/card matching */ /* Arrow Styling - Critical for transparent/card matching */
.driver-popover-override .driver-popover-arrow { .driver-popover-override .driver-popover-arrow {
border-width: 8px !important; border-width: 8px !important;
} }
/* /*
@@ -267,93 +425,105 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
Using CSS variables requires a bit of trickery because border-color expects distinct values. Using CSS variables requires a bit of trickery because border-color expects distinct values.
We'll target the side classes driver.js adds. We'll target the side classes driver.js adds.
*/ */
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-left.driver-popover-arrow { .driver-popover-override.driverjs-theme-dark
border-left-color: var(--card) !important; .driver-popover-arrow-side-left.driver-popover-arrow {
border-left-color: var(--card) !important;
} }
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-right.driver-popover-arrow { .driver-popover-override.driverjs-theme-dark
border-right-color: var(--card) !important; .driver-popover-arrow-side-right.driver-popover-arrow {
border-right-color: var(--card) !important;
} }
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-top.driver-popover-arrow { .driver-popover-override.driverjs-theme-dark
border-top-color: var(--card) !important; .driver-popover-arrow-side-top.driver-popover-arrow {
border-top-color: var(--card) !important;
} }
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-bottom.driver-popover-arrow { .driver-popover-override.driverjs-theme-dark
border-bottom-color: var(--card) !important; .driver-popover-arrow-side-bottom.driver-popover-arrow {
border-bottom-color: var(--card) !important;
} }
/* Light mode fallbacks (using border color for definition, though card bg is usually sufficient) */ /* Light mode fallbacks (using border color for definition, though card bg is usually sufficient) */
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-left.driver-popover-arrow { .driver-popover-override.driverjs-theme-light
border-left-color: var(--card) !important; .driver-popover-arrow-side-left.driver-popover-arrow {
border-left-color: var(--card) !important;
} }
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-right.driver-popover-arrow { .driver-popover-override.driverjs-theme-light
border-right-color: var(--card) !important; .driver-popover-arrow-side-right.driver-popover-arrow {
border-right-color: var(--card) !important;
} }
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-top.driver-popover-arrow { .driver-popover-override.driverjs-theme-light
border-top-color: var(--card) !important; .driver-popover-arrow-side-top.driver-popover-arrow {
border-top-color: var(--card) !important;
} }
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-bottom.driver-popover-arrow { .driver-popover-override.driverjs-theme-light
border-bottom-color: var(--card) !important; .driver-popover-arrow-side-bottom.driver-popover-arrow {
border-bottom-color: var(--card) !important;
} }
/* Title Styling */ /* Title Styling */
.driver-popover-override .driver-popover-title { .driver-popover-override .driver-popover-title {
color: var(--foreground) !important; color: var(--foreground) !important;
font-size: 1.125rem !important; /* 18px */ font-size: 1.125rem !important; /* 18px */
font-weight: 600 !important; font-weight: 600 !important;
margin-bottom: 0.5rem !important; margin-bottom: 0.5rem !important;
letter-spacing: -0.015em !important; letter-spacing: -0.015em !important;
font-family: var(--font-sans) !important; font-family: var(--font-sans) !important;
} }
/* Description Styling */ /* Description Styling */
.driver-popover-override .driver-popover-description { .driver-popover-override .driver-popover-description {
color: var(--muted-foreground) !important; color: var(--muted-foreground) !important;
font-size: 0.875rem !important; /* 14px */ font-size: 0.875rem !important; /* 14px */
line-height: 1.6 !important; line-height: 1.6 !important;
font-family: var(--font-sans) !important; font-family: var(--font-sans) !important;
} }
/* Buttons */ /* Buttons */
.driver-popover-override .driver-popover-footer button { .driver-popover-override .driver-popover-footer button {
background-color: var(--primary) !important; background-color: var(--primary) !important;
color: var(--primary-foreground) !important; color: var(--primary-foreground) !important;
border-radius: calc(var(--radius) - 2px) !important; border-radius: calc(var(--radius) - 2px) !important;
padding: 0.5rem 1rem !important; padding: 0.5rem 1rem !important;
font-size: 0.875rem !important; font-size: 0.875rem !important;
font-weight: 500 !important; font-weight: 500 !important;
border: none !important; border: none !important;
text-shadow: none !important; text-shadow: none !important;
transition-all: 0.2s !important; transition-all: 0.2s !important;
font-family: var(--font-sans) !important; font-family: var(--font-sans) !important;
} }
.driver-popover-override .driver-popover-footer button:hover { .driver-popover-override .driver-popover-footer button:hover {
opacity: 0.9 !important; opacity: 0.9 !important;
transform: translateY(-1px); transform: translateY(-1px);
} }
/* Navigation Buttons (Previous/Next) specifically */ /* Navigation Buttons (Previous/Next) specifically */
.driver-popover-override .driver-popover-footer .driver-popover-prev-btn { .driver-popover-override
background-color: transparent !important; .driver-popover-footer
color: var(--muted-foreground) !important; .driver-popover-prev-btn {
border: 1px solid var(--border) !important; background-color: transparent !important;
color: var(--muted-foreground) !important;
border: 1px solid var(--border) !important;
} }
.driver-popover-override .driver-popover-footer .driver-popover-prev-btn:hover { .driver-popover-override
background-color: var(--accent) !important; .driver-popover-footer
color: var(--accent-foreground) !important; .driver-popover-prev-btn:hover {
background-color: var(--accent) !important;
color: var(--accent-foreground) !important;
} }
/* Close Button */ /* Close Button */
.driver-popover-override .driver-popover-close-btn { .driver-popover-override .driver-popover-close-btn {
color: var(--muted-foreground) !important; color: var(--muted-foreground) !important;
opacity: 0.7 !important; opacity: 0.7 !important;
transition: opacity 0.2s !important; transition: opacity 0.2s !important;
} }
.driver-popover-override .driver-popover-close-btn:hover { .driver-popover-override .driver-popover-close-btn:hover {
color: var(--foreground) !important; color: var(--foreground) !important;
opacity: 1 !important; opacity: 1 !important;
} }
`}</style> `}</style>
</TourContext.Provider> </TourContext.Provider>
); );
} }

View File

@@ -0,0 +1,230 @@
"use client";
import { useState } from "react";
import {
Upload,
X,
FileText,
CheckCircle,
AlertCircle,
Loader2,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import { Progress } from "~/components/ui/progress";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { cn } from "~/lib/utils";
interface ConsentUploadFormProps {
studyId: string;
participantId: string;
consentFormId: string;
onSuccess: () => void;
onCancel: () => void;
}
export function ConsentUploadForm({
studyId,
participantId,
consentFormId,
onSuccess,
onCancel,
}: ConsentUploadFormProps) {
const [file, setFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
// Mutations
const getUploadUrlMutation =
api.participants.getConsentUploadUrl.useMutation();
const recordConsentMutation = api.participants.recordConsent.useMutation();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const selectedFile = e.target.files[0];
// Validate size (10MB)
if (selectedFile.size > 10 * 1024 * 1024) {
toast.error("File too large", {
description: "Maximum file size is 10MB",
});
return;
}
// Validate type
const allowedTypes = [
"application/pdf",
"image/png",
"image/jpeg",
"image/jpg",
];
if (!allowedTypes.includes(selectedFile.type)) {
toast.error("Invalid file type", {
description: "Please upload a PDF, PNG, or JPG file",
});
return;
}
setFile(selectedFile);
}
};
const handleUpload = async () => {
if (!file) return;
try {
setIsUploading(true);
setUploadProgress(0);
// 1. Get Presigned URL
const { url, key } = await getUploadUrlMutation.mutateAsync({
studyId,
participantId,
filename: file.name,
contentType: file.type,
size: file.size,
});
// 2. Upload to MinIO using XMLHttpRequest for progress
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url, true);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentCompleted = Math.round(
(event.loaded * 100) / event.total,
);
setUploadProgress(percentCompleted);
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error("Network error during upload"));
xhr.send(file);
});
// 3. Record Consent in DB
await recordConsentMutation.mutateAsync({
participantId,
consentFormId,
storagePath: key,
});
toast.success("Consent Recorded", {
description:
"The consent form has been uploaded and recorded successfully.",
});
onSuccess();
} catch (error) {
console.error("Upload failed:", error);
toast.error("Upload Failed", {
description:
error instanceof Error
? error.message
: "An unexpected error occurred",
});
setIsUploading(false);
}
};
return (
<div className="space-y-4">
{!file ? (
<div className="bg-muted/5 hover:bg-muted/10 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors">
<Upload className="text-muted-foreground mb-4 h-8 w-8" />
<h3 className="mb-1 text-sm font-semibold">Upload Signed Consent</h3>
<p className="text-muted-foreground mb-4 text-center text-xs">
Drag and drop or click to select
<br />
PDF, PNG, JPG up to 10MB
</p>
<input
type="file"
id="consent-file-upload"
className="hidden"
accept=".pdf,.png,.jpg,.jpeg"
onChange={handleFileChange}
/>
<Button
variant="secondary"
size="sm"
onClick={() =>
document.getElementById("consent-file-upload")?.click()
}
>
Select File
</Button>
</div>
) : (
<div className="bg-muted/5 rounded-lg border p-4">
<div className="mb-4 flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded">
<FileText className="text-primary h-5 w-5" />
</div>
<div>
<p className="line-clamp-1 text-sm font-medium break-all">
{file.name}
</p>
<p className="text-muted-foreground text-xs">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
{!isUploading && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setFile(null)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{isUploading && (
<div className="mb-4 space-y-2">
<div className="text-muted-foreground flex justify-between text-xs">
<span>Uploading...</span>
<span>{uploadProgress}%</span>
</div>
<Progress value={uploadProgress} className="h-2" />
</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={onCancel}
disabled={isUploading}
>
Cancel
</Button>
<Button size="sm" onClick={handleUpload} disabled={isUploading}>
{isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Uploading
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Upload & Record
</>
)}
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,235 @@
"use client";
import { useRef, useState } from "react";
import SignatureCanvas from "react-signature-canvas";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { PenBox, Eraser, Loader2, CheckCircle } from "lucide-react";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { generatePdfBlobFromHtml } from "~/lib/pdf-generator";
import { Editor, EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
import { Table } from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import { ScrollArea } from "~/components/ui/scroll-area";
interface DigitalSignatureModalProps {
studyId: string;
participantId: string;
participantName?: string | null;
participantCode: string;
activeForm: { id: string; content: string; version: number };
onSuccess: () => void;
}
export function DigitalSignatureModal({
studyId,
participantId,
participantName,
participantCode,
activeForm,
onSuccess,
}: DigitalSignatureModalProps) {
const [isOpen, setIsOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const sigCanvas = useRef<any>(null);
// Mutations
const getUploadUrlMutation = api.participants.getConsentUploadUrl.useMutation();
const recordConsentMutation = api.participants.recordConsent.useMutation();
// Create a preview version of the text
let previewMd = activeForm.content;
previewMd = previewMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
previewMd = previewMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
const today = new Date().toLocaleDateString();
previewMd = previewMd.replace(/{{DATE}}/g, today);
previewMd = previewMd.replace(/{{SIGNATURE_IMAGE}}/g, "_[Signature Here]_");
const previewEditor = useEditor({
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
content: previewMd,
editable: false,
immediatelyRender: false,
});
const handleClear = () => {
sigCanvas.current?.clear();
};
const handleSubmit = async () => {
if (sigCanvas.current?.isEmpty()) {
toast.error("Signature required", { description: "Please sign the document before submitting." });
return;
}
try {
setIsSubmitting(true);
toast.loading("Generating Signed Document...", { id: "sig-upload" });
// 1. Get Signature Image Data URL
const signatureDataUrl = sigCanvas.current.getTrimmedCanvas().toDataURL("image/png");
// 2. Prepare final Markdown and HTML
let finalMd = activeForm.content;
finalMd = finalMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
finalMd = finalMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
finalMd = finalMd.replace(/{{DATE}}/g, today);
finalMd = finalMd.replace(/{{SIGNATURE_IMAGE}}/g, `<img src="${signatureDataUrl}" style="height: 60px; max-width: 250px;" />`);
const headlessEditor = new Editor({
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
content: finalMd,
});
const htmlContent = headlessEditor.getHTML();
headlessEditor.destroy();
// 3. Generate PDF Blob
const filename = `Signed_Consent_${participantCode}_v${activeForm.version}.pdf`;
const pdfBlob = await generatePdfBlobFromHtml(htmlContent, { filename });
const file = new File([pdfBlob], filename, { type: "application/pdf" });
// 4. Get Presigned URL
toast.loading("Uploading Document...", { id: "sig-upload" });
const { url, key } = await getUploadUrlMutation.mutateAsync({
studyId,
participantId,
filename: file.name,
contentType: file.type,
size: file.size,
});
// 5. Upload to MinIO
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url, true);
xhr.setRequestHeader("Content-Type", file.type);
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`Upload failed with status ${xhr.status}`));
};
xhr.onerror = () => reject(new Error("Network error during upload"));
xhr.send(file);
});
// 6. Record Consent in DB
toast.loading("Finalizing Consent...", { id: "sig-upload" });
await recordConsentMutation.mutateAsync({
participantId,
consentFormId: activeForm.id,
storagePath: key,
});
toast.success("Consent Successfully Recorded!", { id: "sig-upload" });
setIsOpen(false);
onSuccess();
} catch (error) {
console.error(error);
toast.error("Failed to submit digital signature", {
id: "sig-upload",
description: error instanceof Error ? error.message : "Unknown error",
});
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="default" size="sm" className="bg-primary/90 hover:bg-primary">
<PenBox className="mr-2 h-4 w-4" />
Sign Digitally
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-6">
<DialogHeader>
<DialogTitle>Digital Consent Signature</DialogTitle>
<DialogDescription>
Please review the document below and provide your digital signature to consent to this study.
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0 grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
{/* Document Preview (Left) */}
<div className="flex flex-col border rounded-md overflow-hidden bg-muted/20">
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Document Preview
</div>
<ScrollArea className="flex-1 w-full bg-white p-6 shadow-inner">
<div className="prose prose-sm max-w-none text-black">
<EditorContent editor={previewEditor} />
</div>
</ScrollArea>
</div>
{/* Signature Panel (Right) */}
<div className="flex flex-col space-y-4">
<div className="border rounded-md overflow-hidden bg-white shadow-sm flex flex-col">
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Digital Signature Pad
</div>
<div className="p-4 bg-muted/10 relative">
<div className="absolute top-4 right-4">
<Button variant="ghost" size="sm" onClick={handleClear} disabled={isSubmitting}>
<Eraser className="h-4 w-4 mr-2" />
Clear
</Button>
</div>
<div className="border-2 border-dashed border-input rounded-md bg-white mt-10" style={{ height: "250px" }}>
<SignatureCanvas
ref={sigCanvas}
penColor="black"
canvasProps={{ className: "w-full h-full cursor-crosshair rounded-md" }}
/>
</div>
<p className="text-center text-xs text-muted-foreground mt-2">
Draw your signature using your mouse or touch screen inside the box above.
</p>
</div>
</div>
<div className="flex-1" />
{/* Submission Actions */}
<div className="flex flex-col space-y-3 p-4 bg-primary/5 rounded-lg border border-primary/20">
<h4 className="flex items-center text-sm font-semibold text-primary">
<CheckCircle className="h-4 w-4 mr-2" />
Agreement
</h4>
<p className="text-xs text-muted-foreground leading-relaxed">
By clicking "Submit Signed Document", you confirm that you have read and understood the information provided in the document preview, and you voluntarily agree to participate in this study.
</p>
<Button
className="w-full mt-2"
size="lg"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
"Submit Signed Document"
)}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,238 @@
"use client";
import { useState } from "react";
import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { ConsentUploadForm } from "./ConsentUploadForm";
import {
FileText,
Download,
CheckCircle,
AlertCircle,
Upload,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
import { cn } from "~/lib/utils";
import { downloadPdfFromHtml } from "~/lib/pdf-generator";
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
import { Table } from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import { DigitalSignatureModal } from "./DigitalSignatureModal";
interface ParticipantConsentManagerProps {
studyId: string;
participantId: string;
consentGiven: boolean;
consentDate: Date | null;
existingConsent: {
id: string;
storagePath: string | null;
signedAt: Date;
consentForm: {
title: string;
version: number;
};
} | null;
}
export function ParticipantConsentManager({
studyId,
participantId,
consentGiven,
consentDate,
existingConsent,
participantName,
participantCode,
}: ParticipantConsentManagerProps & { participantName?: string | null; participantCode: string }) {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
// Fetch active consent forms to know which form to sign/upload against
const { data: consentForms } = api.participants.getConsentForms.useQuery({
studyId,
});
const activeForm = consentForms?.find((f) => f.active) ?? consentForms?.[0];
// Helper to get download URL
const { refetch: fetchDownloadUrl } = api.files.getDownloadUrl.useQuery(
{ storagePath: existingConsent?.storagePath ?? "" },
{ enabled: false },
);
const handleDownload = async () => {
if (!existingConsent?.storagePath) return;
try {
const result = await fetchDownloadUrl();
if (result.data?.url) {
window.open(result.data.url, "_blank");
} else {
toast.error("Error", { description: "Could not retrieve document" });
}
} catch (error) {
toast.error("Error", { description: "Failed to get download URL" });
}
};
const handleSuccess = () => {
setIsOpen(false);
utils.participants.get.invalidate({ id: participantId });
toast.success("Success", { description: "Consent recorded successfully" });
};
const handleDownloadUnsigned = async () => {
if (!activeForm) return;
try {
toast.loading("Generating custom document...", { id: "pdf-gen" });
// Substitute placeholders in markdown
let customMd = activeForm.content;
customMd = customMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
customMd = customMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
customMd = customMd.replace(/{{DATE}}/g, "_________________");
customMd = customMd.replace(/{{SIGNATURE_IMAGE}}/g, ""); // Blank ready for physical signature
// Use headless Tiptap to parse MD to HTML via same extensions
const editor = new Editor({
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
content: customMd,
});
const htmlContent = editor.getHTML();
editor.destroy();
await downloadPdfFromHtml(htmlContent, {
filename: `Consent_${participantCode}_${activeForm.version}.pdf`,
});
toast.success("Document Downloaded", { id: "pdf-gen" });
} catch (e) {
toast.error("Error generating customized PDF", { id: "pdf-gen" });
console.error(e);
}
};
return (
<div className="bg-card text-card-foreground rounded-lg border shadow-sm">
<div className="flex flex-row items-center justify-between space-y-0 p-6 pb-2">
<div className="flex flex-col space-y-1.5">
<h3 className="flex items-center gap-2 leading-none font-semibold tracking-tight">
<FileText className="h-5 w-5" />
Consent Status
</h3>
<p className="text-muted-foreground text-sm">
Manage participant consent and forms.
</p>
</div>
<Badge variant={consentGiven ? "default" : "destructive"}>
{consentGiven ? "Consent Given" : "Not Recorded"}
</Badge>
</div>
<div className="p-6 pt-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
{consentGiven ? (
<>
<div className="flex items-center gap-2 text-sm font-medium">
<CheckCircle className="h-4 w-4 text-green-600" />
Signed on{" "}
{consentDate
? new Date(consentDate).toLocaleDateString()
: "Unknown date"}
</div>
{existingConsent && (
<p className="text-muted-foreground text-xs">
Form: {existingConsent.consentForm.title} (v
{existingConsent.consentForm.version})
</p>
)}
</>
) : (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<AlertCircle className="h-4 w-4" />
No consent recorded for this participant.
</div>
)}
</div>
<div className="flex gap-2">
{consentGiven && existingConsent?.storagePath && (
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
Download PDF
</Button>
)}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
size="sm"
variant={consentGiven ? "secondary" : "default"}
>
<Upload className="mr-2 h-4 w-4" />
{consentGiven ? "Update Consent" : "Upload Consent"}
</Button>
</DialogTrigger>
{!consentGiven && activeForm && (
<>
<DigitalSignatureModal
studyId={studyId}
participantId={participantId}
participantName={participantName}
participantCode={participantCode}
activeForm={activeForm}
onSuccess={handleSuccess}
/>
<Button variant="outline" size="sm" onClick={handleDownloadUnsigned}>
<Download className="mr-2 h-4 w-4" />
Print Empty Form
</Button>
</>
)}
<DialogContent>
<DialogHeader>
<DialogTitle>Upload Signed Consent Form</DialogTitle>
<DialogDescription>
Upload the signed PDF or image of the consent form for this
participant.
{activeForm && (
<span className="text-foreground mt-1 block font-medium">
Active Form: {activeForm.title} (v{activeForm.version})
</span>
)}
</DialogDescription>
</DialogHeader>
{activeForm ? (
<ConsentUploadForm
studyId={studyId}
participantId={participantId}
consentFormId={activeForm.id}
onSuccess={handleSuccess}
onCancel={() => setIsOpen(false)}
/>
) : (
<div className="text-muted-foreground py-4 text-center">
No active consent form found for this study. Please create
one first.
</div>
)}
</DialogContent>
</Dialog>
</div>
</div>
</div>
</div>
);
}

View File

@@ -25,7 +25,10 @@ import {
SelectValue, SelectValue,
} from "~/components/ui/select"; } from "~/components/ui/select";
import { useStudyContext } from "~/lib/study-context"; import { useStudyContext } from "~/lib/study-context";
import { cn } from "~/lib/utils";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { useTour } from "~/components/onboarding/TourProvider";
import { Button } from "~/components/ui/button";
type DemographicsData = { type DemographicsData = {
age?: number; age?: number;
@@ -80,6 +83,7 @@ export function ParticipantForm({
studyId, studyId,
}: ParticipantFormProps) { }: ParticipantFormProps) {
const router = useRouter(); const router = useRouter();
const { startTour } = useTour();
const { selectedStudyId } = useStudyContext(); const { selectedStudyId } = useStudyContext();
const contextStudyId = studyId ?? selectedStudyId; const contextStudyId = studyId ?? selectedStudyId;
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -91,6 +95,7 @@ export function ParticipantForm({
defaultValues: { defaultValues: {
consentGiven: false, consentGiven: false,
studyId: contextStudyId ?? "", studyId: contextStudyId ?? "",
participantCode: "",
}, },
}); });
@@ -180,6 +185,20 @@ export function ParticipantForm({
} }
}, [contextStudyId, mode, form]); }, [contextStudyId, mode, form]);
// Fetch next participant code
const { data: nextCode, isLoading: isNextCodeLoading } =
api.participants.getNextCode.useQuery(
{ studyId: contextStudyId! },
{ enabled: mode === "create" && !!contextStudyId },
);
// Update default value if we switch modes or remount
useEffect(() => {
if (mode === "create" && nextCode) {
form.setValue("participantCode", nextCode, { shouldValidate: true });
}
}, [mode, nextCode, form]);
const createParticipantMutation = api.participants.create.useMutation(); const createParticipantMutation = api.participants.create.useMutation();
const updateParticipantMutation = api.participants.update.useMutation(); const updateParticipantMutation = api.participants.update.useMutation();
const deleteParticipantMutation = api.participants.delete.useMutation(); const deleteParticipantMutation = api.participants.delete.useMutation();
@@ -203,7 +222,9 @@ export function ParticipantForm({
email: data.email ?? undefined, email: data.email ?? undefined,
demographics, demographics,
}); });
router.push(`/studies/${data.studyId}/participants/${newParticipant.id}`); router.push(
`/studies/${data.studyId}/participants/${newParticipant.id}`,
);
} else { } else {
const updatedParticipant = await updateParticipantMutation.mutateAsync({ const updatedParticipant = await updateParticipantMutation.mutateAsync({
id: participantId!, id: participantId!,
@@ -212,7 +233,9 @@ export function ParticipantForm({
email: data.email ?? undefined, email: data.email ?? undefined,
demographics, demographics,
}); });
router.push(`/studies/${contextStudyId}/participants/${updatedParticipant.id}`); router.push(
`/studies/${contextStudyId}/participants/${updatedParticipant.id}`,
);
} }
} catch (error) { } catch (error) {
setError( setError(
@@ -256,160 +279,154 @@ export function ParticipantForm({
<> <>
<FormSection <FormSection
title="Participant Information" title="Participant Information"
description="Basic information about the research participant." description="Basic identity and study association."
> >
<FormField> <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
<Label htmlFor="participantCode">Participant Code *</Label> <FormField>
<Input <Label htmlFor="participantCode">Participant Code *</Label>
id="participantCode" <Input
{...form.register("participantCode")} id="tour-participant-code"
placeholder="e.g., P001, SUBJ_01, etc." {...form.register("participantCode")}
className={ placeholder={isNextCodeLoading ? "Generating..." : "e.g., P001"}
form.formState.errors.participantCode ? "border-red-500" : "" readOnly={true}
} className={cn(
/> "bg-muted text-muted-foreground",
{form.formState.errors.participantCode && ( form.formState.errors.participantCode ? "border-red-500" : ""
<p className="text-sm text-red-600"> )}
{form.formState.errors.participantCode.message} />
</p> {form.formState.errors.participantCode && (
)} <p className="text-sm text-red-600">
<p className="text-muted-foreground text-xs"> {form.formState.errors.participantCode.message}
Unique identifier for this participant within the study </p>
</p> )}
</FormField> </FormField>
<FormField> <FormField>
<Label htmlFor="name">Full Name</Label> <Label htmlFor="name">Full Name</Label>
<Input <Input
id="name" id="tour-participant-name"
{...form.register("name")} {...form.register("name")}
placeholder="Optional: Participant's full name" placeholder="Optional name"
className={form.formState.errors.name ? "border-red-500" : ""} className={form.formState.errors.name ? "border-red-500" : ""}
/> />
{form.formState.errors.name && ( {form.formState.errors.name && (
<p className="text-sm text-red-600"> <p className="text-sm text-red-600">
{form.formState.errors.name.message} {form.formState.errors.name.message}
</p> </p>
)} )}
<p className="text-muted-foreground text-xs"> </FormField>
Optional: Real name for contact purposes
</p>
</FormField>
<FormField> <FormField>
<Label htmlFor="email">Email Address</Label> <Label htmlFor="email">Email Address</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
{...form.register("email")} {...form.register("email")}
placeholder="participant@example.com" placeholder="participant@example.com"
className={form.formState.errors.email ? "border-red-500" : ""} className={form.formState.errors.email ? "border-red-500" : ""}
/> />
{form.formState.errors.email && ( {form.formState.errors.email && (
<p className="text-sm text-red-600"> <p className="text-sm text-red-600">
{form.formState.errors.email.message} {form.formState.errors.email.message}
</p> </p>
)} )}
<p className="text-muted-foreground text-xs"> </FormField>
Optional: For scheduling and communication </div>
</p>
</FormField>
<FormField>
<Label htmlFor="studyId">Study *</Label>
<Select
value={form.watch("studyId")}
onValueChange={(value) => form.setValue("studyId", value)}
disabled={studiesLoading || mode === "edit"}
>
<SelectTrigger
className={form.formState.errors.studyId ? "border-red-500" : ""}
>
<SelectValue
placeholder={
studiesLoading ? "Loading studies..." : "Select a study"
}
/>
</SelectTrigger>
<SelectContent>
{studiesData?.studies?.map((study) => (
<SelectItem key={study.id} value={study.id}>
{study.name}
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.studyId && (
<p className="text-sm text-red-600">
{form.formState.errors.studyId.message}
</p>
)}
{mode === "edit" && (
<p className="text-muted-foreground text-xs">
Study cannot be changed after registration
</p>
)}
</FormField>
</FormSection> </FormSection>
<FormSection <div className="my-6" />
title="Demographics"
description="Optional demographic information for research purposes."
>
<FormField>
<Label htmlFor="age">Age</Label>
<Input
id="age"
type="number"
min="18"
max="120"
{...form.register("age", { valueAsNumber: true })}
placeholder="e.g., 25"
className={form.formState.errors.age ? "border-red-500" : ""}
/>
{form.formState.errors.age && (
<p className="text-sm text-red-600">
{form.formState.errors.age.message}
</p>
)}
<p className="text-muted-foreground text-xs">
Optional: Age in years (minimum 18)
</p>
</FormField>
<FormField> <FormSection
<Label htmlFor="gender">Gender</Label> title={contextStudyId ? "Demographics" : "Demographics & Study"}
<Select description={contextStudyId ? "Participant demographic details." : "Study association and demographic details."}
value={form.watch("gender") ?? ""} >
onValueChange={(value) => <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
form.setValue( {!contextStudyId && (
"gender", <FormField>
value as <Label htmlFor="studyId" id="tour-participant-study-label">
| "male" Study *
| "female" </Label>
| "non_binary" <div id="tour-participant-study-container">
| "prefer_not_to_say" <Select
| "other", value={form.watch("studyId")}
) onValueChange={(value) => form.setValue("studyId", value)}
} disabled={studiesLoading || mode === "edit"}
> >
<SelectTrigger> <SelectTrigger
<SelectValue placeholder="Select gender (optional)" /> className={
</SelectTrigger> form.formState.errors.studyId ? "border-red-500" : ""
<SelectContent> }
<SelectItem value="male">Male</SelectItem> >
<SelectItem value="female">Female</SelectItem> <SelectValue
<SelectItem value="non_binary">Non-binary</SelectItem> placeholder={studiesLoading ? "Loading..." : "Select study"}
<SelectItem value="prefer_not_to_say"> />
Prefer not to say </SelectTrigger>
</SelectItem> <SelectContent>
<SelectItem value="other">Other</SelectItem> {studiesData?.studies?.map((study) => (
</SelectContent> <SelectItem key={study.id} value={study.id}>
</Select> {study.name}
<p className="text-muted-foreground text-xs"> </SelectItem>
Optional: Gender identity for demographic analysis ))}
</p> </SelectContent>
</FormField> </Select>
{form.formState.errors.studyId && (
<p className="text-sm text-red-600">
{form.formState.errors.studyId.message}
</p>
)}
</div>
</FormField>
)}
<FormField>
<Label htmlFor="age">Age</Label>
<Input
id="age"
type="number"
min="18"
max="120"
{...form.register("age", { valueAsNumber: true })}
placeholder="e.g., 25"
className={form.formState.errors.age ? "border-red-500" : ""}
/>
{form.formState.errors.age && (
<p className="text-sm text-red-600">
{form.formState.errors.age.message}
</p>
)}
</FormField>
<FormField>
<Label htmlFor="gender">Gender</Label>
<Select
value={form.watch("gender") ?? ""}
onValueChange={(value) =>
form.setValue(
"gender",
value as
| "male"
| "female"
| "non_binary"
| "prefer_not_to_say"
| "other",
)
}
>
<SelectTrigger>
<SelectValue placeholder="Select gender" />
</SelectTrigger>
<SelectContent>
<SelectItem value="male">Male</SelectItem>
<SelectItem value="female">Female</SelectItem>
<SelectItem value="non_binary">Non-binary</SelectItem>
<SelectItem value="prefer_not_to_say">
Prefer not to say
</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</FormField>
</div>
</FormSection> </FormSection>
{mode === "create" && ( {mode === "create" && (
@@ -420,7 +437,7 @@ export function ParticipantForm({
<FormField> <FormField>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="consentGiven" id="tour-participant-consent"
checked={form.watch("consentGiven")} checked={form.watch("consentGiven")}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
form.setValue("consentGiven", !!checked) form.setValue("consentGiven", !!checked)
@@ -505,9 +522,25 @@ export function ParticipantForm({
error={error} error={error}
onDelete={mode === "edit" ? onDelete : undefined} onDelete={mode === "edit" ? onDelete : undefined}
isDeleting={isDeleting} isDeleting={isDeleting}
sidebar={mode === "create" ? sidebar : undefined}
// sidebar={sidebar} // Removed for cleaner UI per user request
submitText={mode === "create" ? "Register Participant" : "Save Changes"} submitText={mode === "create" ? "Register Participant" : "Save Changes"}
submitButtonId="tour-participant-submit"
extraActions={
mode === "create" ? (
<Button
variant="ghost"
size="sm"
onClick={() => startTour("participant_creation")}
>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Help</span>
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded-full border text-xs">
?
</div>
</div>
</Button>
) : undefined
}
> >
{formFields} {formFields}
</EntityForm> </EntityForm>

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { type ColumnDef } from "@tanstack/react-table"; import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, Mail, Trash2 } from "lucide-react"; import { ArrowUpDown, MoreHorizontal, Edit, Trash2 } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
@@ -24,6 +24,12 @@ import {
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { useStudyContext } from "~/lib/study-context"; import { useStudyContext } from "~/lib/study-context";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
export type Participant = { export type Participant = {
id: string; id: string;
@@ -101,16 +107,32 @@ export const columns: ColumnDef<Participant>[] = [
const name = row.getValue("name"); const name = row.getValue("name");
const email = row.original.email; const email = row.original.email;
return ( return (
<div> <TooltipProvider>
<div className="truncate font-medium"> <div>
{String(name) || "No name provided"} <div className="max-w-[200px] truncate font-medium">
</div> <Tooltip>
{email && ( <TooltipTrigger asChild>
<div className="text-muted-foreground truncate text-sm"> <span>{String(name) || "No name provided"}</span>
{email} </TooltipTrigger>
<TooltipContent>
<p>{String(name) || "No name provided"}</p>
</TooltipContent>
</Tooltip>
</div> </div>
)} {email && (
</div> <div className="text-muted-foreground max-w-[200px] truncate text-sm">
<Tooltip>
<TooltipTrigger asChild>
<span>{email}</span>
</TooltipTrigger>
<TooltipContent>
<p>{email}</p>
</TooltipContent>
</Tooltip>
</div>
)}
</div>
</TooltipProvider>
); );
}, },
}, },
@@ -120,11 +142,30 @@ export const columns: ColumnDef<Participant>[] = [
cell: ({ row }) => { cell: ({ row }) => {
const consentGiven = row.getValue("consentGiven"); const consentGiven = row.getValue("consentGiven");
if (consentGiven) { return (
return <Badge className="bg-green-100 text-green-800">Consented</Badge>; <TooltipProvider>
} <Tooltip>
<TooltipTrigger>
return <Badge className="bg-red-100 text-red-800">Pending</Badge>; {consentGiven ? (
<Badge className="bg-green-100 text-green-800 hover:bg-green-200">
Consented
</Badge>
) : (
<Badge className="bg-red-100 text-red-800 hover:bg-red-200">
Pending
</Badge>
)}
</TooltipTrigger>
<TooltipContent>
<p>
{consentGiven
? "Participant has signed the consent form."
: "Consent form has not been recorded."}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}, },
}, },
{ {
@@ -148,30 +189,7 @@ export const columns: ColumnDef<Participant>[] = [
); );
}, },
}, },
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const date = row.getValue("createdAt");
return (
<div className="text-muted-foreground text-sm">
{formatDistanceToNow(new Date(date as string | number | Date), {
addSuffix: true,
})}
</div>
);
},
},
{ {
id: "actions", id: "actions",
enableHiding: false, enableHiding: false,
@@ -195,30 +213,21 @@ export const columns: ColumnDef<Participant>[] = [
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(participant.id)}
>
<Copy className="mr-2 h-4 w-4" />
Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/studies/${studyId}/participants/${participant.id}/edit`}> <Link
href={`/studies/${studyId}/participants/${participant.id}/edit`}
>
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Edit participant Edit participant
</Link > </Link>
</DropdownMenuItem >
<DropdownMenuItem disabled>
<Mail className="mr-2 h-4 w-4" />
Send consent
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600"> <DropdownMenuItem className="text-red-600">
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Remove Remove
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent > </DropdownMenuContent>
</DropdownMenu > </DropdownMenu>
); );
}, },
}, },

View File

@@ -32,7 +32,7 @@ import {
SelectValue, SelectValue,
} from "~/components/ui/select"; } from "~/components/ui/select";
import { Mail, Plus, UserPlus } from "lucide-react"; import { Mail, Plus, UserPlus, Microscope, Wand2, Eye } from "lucide-react";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { useStudyManagement } from "~/hooks/useStudyManagement"; import { useStudyManagement } from "~/hooks/useStudyManagement";
@@ -54,17 +54,17 @@ const roleDescriptions = {
researcher: { researcher: {
label: "Researcher", label: "Researcher",
description: "Can manage experiments, view all data, and invite members", description: "Can manage experiments, view all data, and invite members",
icon: "🔬", icon: Microscope,
}, },
wizard: { wizard: {
label: "Wizard", label: "Wizard",
description: "Can control trials and execute experiments", description: "Can control trials and execute experiments",
icon: "🎭", icon: Wand2,
}, },
observer: { observer: {
label: "Observer", label: "Observer",
description: "Read-only access to view trials and data", description: "Read-only access to view trials and data",
icon: "👁️", icon: Eye,
}, },
}; };
@@ -167,7 +167,10 @@ export function InviteMemberDialog({
([value, config]) => ( ([value, config]) => (
<SelectItem key={value} value={value}> <SelectItem key={value} value={value}>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span>{config.icon}</span> {(() => {
const Icon = config.icon;
return <Icon className="h-4 w-4" />;
})()}
<span>{config.label}</span> <span>{config.label}</span>
</div> </div>
</SelectItem> </SelectItem>
@@ -180,8 +183,18 @@ export function InviteMemberDialog({
<div className="mt-2 rounded-lg bg-slate-50 p-3"> <div className="mt-2 rounded-lg bg-slate-50 p-3">
<div className="mb-1 flex items-center space-x-2"> <div className="mb-1 flex items-center space-x-2">
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{roleDescriptions[field.value].icon}{" "} {(() => {
{roleDescriptions[field.value].label} const Icon =
roleDescriptions[
field.value as keyof typeof roleDescriptions
].icon;
return <Icon className="mr-1 h-3.5 w-3.5" />;
})()}
{
roleDescriptions[
field.value as keyof typeof roleDescriptions
].label
}
</Badge> </Badge>
</div> </div>
<p className="text-xs text-slate-600"> <p className="text-xs text-slate-600">

View File

@@ -5,7 +5,14 @@ import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { AlertCircle, Filter } from "lucide-react"; import {
AlertCircle,
Filter,
Activity,
FileEdit,
CheckCircle2,
Archive,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Alert, AlertDescription } from "~/components/ui/alert"; import { Alert, AlertDescription } from "~/components/ui/alert";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
@@ -14,12 +21,12 @@ import { Card, CardContent } from "~/components/ui/card";
import { Checkbox } from "~/components/ui/checkbox"; import { Checkbox } from "~/components/ui/checkbox";
import { DataTable } from "~/components/ui/data-table"; import { DataTable } from "~/components/ui/data-table";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
@@ -69,22 +76,22 @@ const statusConfig = {
draft: { draft: {
label: "Draft", label: "Draft",
className: "bg-gray-100 text-gray-800", className: "bg-gray-100 text-gray-800",
icon: "📝", icon: FileEdit,
}, },
active: { active: {
label: "Active", label: "Active",
className: "bg-green-100 text-green-800", className: "bg-green-100 text-green-800",
icon: "🟢", icon: Activity,
}, },
completed: { completed: {
label: "Completed", label: "Completed",
className: "bg-blue-100 text-blue-800", className: "bg-blue-100 text-blue-800",
icon: "✅", icon: CheckCircle2,
}, },
archived: { archived: {
label: "Archived", label: "Archived",
className: "bg-orange-100 text-orange-800", className: "bg-orange-100 text-orange-800",
icon: "📦", icon: Archive,
}, },
}; };
@@ -172,7 +179,7 @@ export const columns: ColumnDef<Study>[] = [
const statusInfo = statusConfig[status as keyof typeof statusConfig]; const statusInfo = statusConfig[status as keyof typeof statusConfig];
return ( return (
<Badge className={statusInfo.className}> <Badge className={statusInfo.className}>
<span className="mr-1">{statusInfo.icon}</span> <statusInfo.icon className="mr-1 h-3.5 w-3.5" />
{statusInfo.label} {statusInfo.label}
</Badge> </Badge>
); );
@@ -186,7 +193,9 @@ export const columns: ColumnDef<Study>[] = [
const isOwner = row.original.isOwner; const isOwner = row.original.isOwner;
return ( return (
<Badge variant={isOwner ? "default" : "secondary"}>{String(userRole)}</Badge> <Badge variant={isOwner ? "default" : "secondary"}>
{String(userRole)}
</Badge>
); );
}, },
}, },
@@ -215,7 +224,9 @@ export const columns: ColumnDef<Study>[] = [
); );
} }
return ( return (
<Badge className="bg-blue-100 text-blue-800">{Number(experimentCount)}</Badge> <Badge className="bg-blue-100 text-blue-800">
{Number(experimentCount)}
</Badge>
); );
}, },
}, },
@@ -257,7 +268,9 @@ export const columns: ColumnDef<Study>[] = [
return ( return (
<div className="max-w-[120px]"> <div className="max-w-[120px]">
<div className="text-sm"> <div className="text-sm">
{formatDistanceToNow(new Date(date as string | number | Date), { addSuffix: true })} {formatDistanceToNow(new Date(date as string | number | Date), {
addSuffix: true,
})}
</div> </div>
<div className="text-muted-foreground truncate text-xs"> <div className="text-muted-foreground truncate text-xs">
by {createdBy} by {createdBy}

View File

@@ -1,15 +1,16 @@
"use client"; "use client";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { CheckCircle2, Activity, FileEdit, Archive } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle CardTitle,
} from "~/components/ui/card"; } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
@@ -45,22 +46,22 @@ const statusConfig = {
draft: { draft: {
label: "Draft", label: "Draft",
className: "bg-gray-100 text-gray-800 hover:bg-gray-200", className: "bg-gray-100 text-gray-800 hover:bg-gray-200",
icon: "📝", icon: FileEdit,
}, },
active: { active: {
label: "Active", label: "Active",
className: "bg-green-100 text-green-800 hover:bg-green-200", className: "bg-green-100 text-green-800 hover:bg-green-200",
icon: "🟢", icon: Activity,
}, },
completed: { completed: {
label: "Completed", label: "Completed",
className: "bg-blue-100 text-blue-800 hover:bg-blue-200", className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
icon: "✅", icon: CheckCircle2,
}, },
archived: { archived: {
label: "Archived", label: "Archived",
className: "bg-orange-100 text-orange-800 hover:bg-orange-200", className: "bg-orange-100 text-orange-800 hover:bg-orange-200",
icon: "📦", icon: Archive,
}, },
}; };
@@ -84,7 +85,7 @@ export function StudyCard({ study, userRole, isOwner }: StudyCardProps) {
</CardDescription> </CardDescription>
</div> </div>
<Badge className={statusInfo.className} variant="secondary"> <Badge className={statusInfo.className} variant="secondary">
<span className="mr-1">{statusInfo.icon}</span> <statusInfo.icon className="mr-1 h-3.5 w-3.5" />
{statusInfo.label} {statusInfo.label}
</Badge> </Badge>
</div> </div>

View File

@@ -14,6 +14,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "~/components/ui/select"; } from "~/components/ui/select";
import { Separator } from "~/components/ui/separator";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { import {
EntityForm, EntityForm,
@@ -30,10 +31,7 @@ import { Button } from "../ui/button";
const studySchema = z.object({ const studySchema = z.object({
name: z.string().min(1, "Study name is required").max(255, "Name too long"), name: z.string().min(1, "Study name is required").max(255, "Name too long"),
description: z description: z.string().max(1000, "Description too long").optional(),
.string()
.min(10, "Description must be at least 10 characters")
.max(1000, "Description too long"),
institution: z institution: z
.string() .string()
.min(1, "Institution is required") .min(1, "Institution is required")
@@ -114,7 +112,7 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
institution: data.institution, institution: data.institution,
irbProtocol: data.irbProtocolNumber ?? undefined, irbProtocol: data.irbProtocolNumber ?? undefined,
}); });
router.push(`/studies/${newStudy.id}`); router.push(`/studies/${newStudy.id}/participants/new`);
} else { } else {
const updatedStudy = await updateStudyMutation.mutateAsync({ const updatedStudy = await updateStudyMutation.mutateAsync({
id: studyId!, id: studyId!,
@@ -165,103 +163,126 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
// Form fields // Form fields
const formFields = ( const formFields = (
<FormSection <div className="space-y-6">
title="Study Details" <FormSection
description="Basic information about your research study." title="Study Details"
> description="Basic information and status of your research study."
<FormField> >
<Label htmlFor="tour-study-name">Study Name *</Label> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<Input <FormField>
id="tour-study-name" <Label htmlFor="tour-study-name">Study Name *</Label>
{...form.register("name")} <Input
placeholder="Enter study name..." id="tour-study-name"
className={form.formState.errors.name ? "border-red-500" : ""} {...form.register("name")}
/> placeholder="Enter study name..."
{form.formState.errors.name && ( className={form.formState.errors.name ? "border-red-500" : ""}
<p className="text-sm text-red-600"> />
{form.formState.errors.name.message} {form.formState.errors.name && (
</p> <p className="text-sm text-red-600">
)} {form.formState.errors.name.message}
</FormField> </p>
)}
</FormField>
<FormField> <FormField>
<Label htmlFor="tour-study-description">Description *</Label> <Label htmlFor="status">Status</Label>
<Textarea <Select
id="tour-study-description" value={form.watch("status")}
{...form.register("description")} onValueChange={(value) =>
placeholder="Describe the research objectives, methodology, and expected outcomes..." form.setValue(
rows={4} "status",
className={form.formState.errors.description ? "border-red-500" : ""} value as "draft" | "active" | "completed" | "archived",
/> )
{form.formState.errors.description && ( }
<p className="text-sm text-red-600"> >
{form.formState.errors.description.message} <SelectTrigger>
</p> <SelectValue placeholder="Select status" />
)} </SelectTrigger>
</FormField> <SelectContent>
<SelectItem value="draft">
Draft - Study in preparation
</SelectItem>
<SelectItem value="active">
Active - Currently recruiting/running
</SelectItem>
<SelectItem value="completed">
Completed - Data collection finished
</SelectItem>
<SelectItem value="archived">
Archived - Study concluded
</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField> <div className="md:col-span-2">
<Label htmlFor="institution">Institution *</Label> <FormField>
<Input <Label htmlFor="tour-study-description">Description</Label>
id="institution" <Textarea
{...form.register("institution")} id="tour-study-description"
placeholder="e.g., University of Technology" {...form.register("description")}
className={form.formState.errors.institution ? "border-red-500" : ""} placeholder="Describe the research objectives, methodology, and expected outcomes..."
/> rows={4}
{form.formState.errors.institution && ( className={
<p className="text-sm text-red-600"> form.formState.errors.description ? "border-red-500" : ""
{form.formState.errors.institution.message} }
</p> />
)} {form.formState.errors.description && (
</FormField> <p className="text-sm text-red-600">
{form.formState.errors.description.message}
</p>
)}
</FormField>
</div>
</div>
</FormSection>
<FormField> <Separator />
<Label htmlFor="irbProtocolNumber">IRB Protocol Number</Label>
<Input
id="irbProtocolNumber"
{...form.register("irbProtocolNumber")}
placeholder="e.g., IRB-2024-001"
className={
form.formState.errors.irbProtocolNumber ? "border-red-500" : ""
}
/>
{form.formState.errors.irbProtocolNumber && (
<p className="text-sm text-red-600">
{form.formState.errors.irbProtocolNumber.message}
</p>
)}
<p className="text-muted-foreground text-xs">
Optional: Institutional Review Board protocol number if applicable
</p>
</FormField>
<FormField> <FormSection
<Label htmlFor="status">Status</Label> title="Configuration"
<Select description="Institutional details and ethics approval."
value={form.watch("status")} >
onValueChange={(value) => <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
form.setValue( <FormField>
"status", <Label htmlFor="institution">Institution *</Label>
value as "draft" | "active" | "completed" | "archived", <Input
) id="institution"
} {...form.register("institution")}
> placeholder="e.g., University of Technology"
<SelectTrigger> className={
<SelectValue placeholder="Select status" /> form.formState.errors.institution ? "border-red-500" : ""
</SelectTrigger> }
<SelectContent> />
<SelectItem value="draft">Draft - Study in preparation</SelectItem> {form.formState.errors.institution && (
<SelectItem value="active"> <p className="text-sm text-red-600">
Active - Currently recruiting/running {form.formState.errors.institution.message}
</SelectItem> </p>
<SelectItem value="completed"> )}
Completed - Data collection finished </FormField>
</SelectItem>
<SelectItem value="archived">Archived - Study concluded</SelectItem> <FormField>
</SelectContent> <Label htmlFor="irbProtocolNumber">IRB Protocol Number</Label>
</Select> <Input
</FormField> id="irbProtocolNumber"
</FormSection> {...form.register("irbProtocolNumber")}
placeholder="e.g., IRB-2024-001"
className={
form.formState.errors.irbProtocolNumber ? "border-red-500" : ""
}
/>
{form.formState.errors.irbProtocolNumber && (
<p className="text-sm text-red-600">
{form.formState.errors.irbProtocolNumber.message}
</p>
)}
<p className="text-muted-foreground text-xs">
Optional: Institutional Review Board protocol number if applicable
</p>
</FormField>
</div>
</FormSection>
</div>
); );
// Sidebar content // Sidebar content
@@ -324,13 +345,19 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
error={error} error={error}
onDelete={mode === "edit" ? onDelete : undefined} onDelete={mode === "edit" ? onDelete : undefined}
isDeleting={isDeleting} isDeleting={isDeleting}
sidebar={sidebar} sidebar={mode === "create" ? sidebar : undefined}
submitButtonId="tour-study-submit" submitButtonId="tour-study-submit"
extraActions={ extraActions={
<Button variant="ghost" size="sm" onClick={() => startTour("study_creation")}> <Button
variant="ghost"
size="sm"
onClick={() => startTour("study_creation")}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground">Help</span> <span className="text-muted-foreground">Help</span>
<div className="flex h-5 w-5 items-center justify-center rounded-full border text-xs text-muted-foreground">?</div> <div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded-full border text-xs">
?
</div>
</div> </div>
</Button> </Button>
} }

View File

@@ -4,10 +4,10 @@ import { Monitor, Moon, Sun } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { useTheme } from "./theme-provider"; import { useTheme } from "./theme-provider";
@@ -18,8 +18,8 @@ export function ThemeToggle() {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="icon"> <Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> <Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> <Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span> <span className="sr-only">Toggle theme</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>

View File

@@ -11,7 +11,6 @@ import {
EntityForm, EntityForm,
FormField, FormField,
FormSection, FormSection,
NextSteps,
Tips, Tips,
} from "~/components/ui/entity-form"; } from "~/components/ui/entity-form";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
@@ -27,10 +26,117 @@ import { Textarea } from "~/components/ui/textarea";
import { useStudyContext } from "~/lib/study-context"; import { useStudyContext } from "~/lib/study-context";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { Calendar as CalendarIcon, Clock } from "lucide-react";
import { format } from "date-fns";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import { Calendar } from "~/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { Controller } from "react-hook-form";
// Custom DatePickerTime component based on user request
function DateTimePicker({
value,
onChange,
}: {
value: Date | undefined;
onChange: (date: Date | undefined) => void;
}) {
const [open, setOpen] = useState(false);
// Parse time from value or default
const timeValue = value ? format(value, "HH:mm") : "12:00";
const onDateSelect = (newDate: Date | undefined) => {
if (!newDate) {
onChange(undefined);
setOpen(false);
return;
}
// Preserve existing time or use default
const [hours, minutes] = timeValue.split(":").map(Number);
const updatedDate = new Date(newDate);
updatedDate.setHours(hours || 0);
updatedDate.setMinutes(minutes || 0);
updatedDate.setSeconds(0);
onChange(updatedDate);
setOpen(false);
};
const onTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = e.target.value;
if (!value) return; // Can't set time without date
const [hours, minutes] = newTime.split(":").map(Number);
const updatedDate = new Date(value);
updatedDate.setHours(hours || 0);
updatedDate.setMinutes(minutes || 0);
updatedDate.setSeconds(0);
onChange(updatedDate);
};
return (
<div className="flex items-end gap-2">
<div className="grid gap-1.5">
<Label htmlFor="date-picker" className="text-xs">
Date
</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant={"outline"}
id="date-picker"
className={cn(
"w-[240px] justify-start text-left font-normal",
!value && "text-muted-foreground",
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value ? format(value, "PPP") : <span>Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={value}
onSelect={onDateSelect}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div className="grid gap-1.5">
<Label htmlFor="time-picker" className="text-xs">
Time
</Label>
<div className="relative">
<Input
id="time-picker"
type="time"
value={timeValue}
onChange={onTimeChange}
disabled={!value}
className="w-[120px]"
/>
<Clock className="text-muted-foreground pointer-events-none absolute top-2.5 right-3 h-4 w-4" />
</div>
</div>
</div>
);
}
const trialSchema = z.object({ const trialSchema = z.object({
experimentId: z.string().uuid("Please select an experiment"), experimentId: z.string().uuid("Please select an experiment"),
participantId: z.string().uuid("Please select a participant"), participantId: z.string().uuid("Please select a participant"),
scheduledAt: z.string().min(1, "Please select a date and time"), scheduledAt: z.date(),
wizardId: z.string().uuid().optional(), wizardId: z.string().uuid().optional(),
notes: z.string().max(1000, "Notes cannot exceed 1000 characters").optional(), notes: z.string().max(1000, "Notes cannot exceed 1000 characters").optional(),
sessionNumber: z sessionNumber: z
@@ -52,7 +158,6 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
const { selectedStudyId } = useStudyContext(); const { selectedStudyId } = useStudyContext();
const contextStudyId = studyId ?? selectedStudyId; const contextStudyId = studyId ?? selectedStudyId;
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [isDeleting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const form = useForm<TrialFormData>({ const form = useForm<TrialFormData>({
@@ -90,39 +195,55 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
const { data: usersData, isLoading: usersLoading } = const { data: usersData, isLoading: usersLoading } =
api.users.getWizards.useQuery(); api.users.getWizards.useQuery();
// Auto-increment session number
const selectedParticipantId = form.watch("participantId");
const { data: latestSession } = api.trials.getLatestSession.useQuery(
{ participantId: selectedParticipantId },
{
enabled: !!selectedParticipantId && mode === "create",
refetchOnWindowFocus: false,
},
);
useEffect(() => {
if (latestSession !== undefined && mode === "create") {
form.setValue("sessionNumber", latestSession + 1);
}
}, [latestSession, mode, form]);
// Set breadcrumbs // Set breadcrumbs
const breadcrumbs = [ const breadcrumbs = [
{ label: "Dashboard", href: "/dashboard" }, { label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" }, { label: "Studies", href: "/studies" },
...(contextStudyId ...(contextStudyId
? [ ? [
{ {
label: "Study", label: "Study",
href: `/studies/${contextStudyId}`, href: `/studies/${contextStudyId}`,
}, },
{ label: "Trials", href: `/studies/${contextStudyId}/trials` }, { label: "Trials", href: `/studies/${contextStudyId}/trials` },
...(mode === "edit" && trial ...(mode === "edit" && trial
? [ ? [
{ {
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`, label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
href: `/studies/${contextStudyId}/trials/${trial.id}`, href: `/studies/${contextStudyId}/trials/${trial.id}`,
}, },
{ label: "Edit" }, { label: "Edit" },
] ]
: [{ label: "New Trial" }]), : [{ label: "New Trial" }]),
] ]
: [ : [
{ label: "Trials", href: `/studies/${contextStudyId}/trials` }, { label: "Trials", href: `/studies/${contextStudyId}/trials` },
...(mode === "edit" && trial ...(mode === "edit" && trial
? [ ? [
{ {
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`, label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
href: `/studies/${contextStudyId}/trials/${trial.id}`, href: `/studies/${contextStudyId}/trials/${trial.id}`,
}, },
{ label: "Edit" }, { label: "Edit" },
] ]
: [{ label: "New Trial" }]), : [{ label: "New Trial" }]),
]), ]),
]; ];
useBreadcrumbsEffect(breadcrumbs); useBreadcrumbsEffect(breadcrumbs);
@@ -134,8 +255,8 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
experimentId: trial.experimentId, experimentId: trial.experimentId,
participantId: trial?.participantId ?? "", participantId: trial?.participantId ?? "",
scheduledAt: trial.scheduledAt scheduledAt: trial.scheduledAt
? new Date(trial.scheduledAt).toISOString().slice(0, 16) ? new Date(trial.scheduledAt)
: "", : undefined,
wizardId: trial.wizardId ?? undefined, wizardId: trial.wizardId ?? undefined,
notes: trial.notes ?? "", notes: trial.notes ?? "",
sessionNumber: trial.sessionNumber ?? 1, sessionNumber: trial.sessionNumber ?? 1,
@@ -153,24 +274,26 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
try { try {
if (mode === "create") { if (mode === "create") {
const newTrial = await createTrialMutation.mutateAsync({ await createTrialMutation.mutateAsync({
experimentId: data.experimentId, experimentId: data.experimentId,
participantId: data.participantId, participantId: data.participantId,
scheduledAt: new Date(data.scheduledAt), scheduledAt: data.scheduledAt,
wizardId: data.wizardId, wizardId: data.wizardId,
sessionNumber: data.sessionNumber ?? 1, sessionNumber: data.sessionNumber ?? 1,
notes: data.notes ?? undefined, notes: data.notes ?? undefined,
}); });
router.push(`/studies/${contextStudyId}/trials/${newTrial!.id}`); // Redirect to trials table instead of detail page
router.push(`/studies/${contextStudyId}/trials`);
} else { } else {
const updatedTrial = await updateTrialMutation.mutateAsync({ await updateTrialMutation.mutateAsync({
id: trialId!, id: trialId!,
scheduledAt: new Date(data.scheduledAt), scheduledAt: data.scheduledAt,
wizardId: data.wizardId, wizardId: data.wizardId,
sessionNumber: data.sessionNumber ?? 1, sessionNumber: data.sessionNumber ?? 1,
notes: data.notes ?? undefined, notes: data.notes ?? undefined,
}); });
router.push(`/studies/${contextStudyId}/trials/${updatedTrial!.id}`); // Redirect to trials table on update too
router.push(`/studies/${contextStudyId}/trials`);
} }
} catch (error) { } catch (error) {
setError( setError(
@@ -181,9 +304,6 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
} }
}; };
// Delete handler (trials cannot be deleted in this version)
const onDelete = undefined;
// Loading state for edit mode // Loading state for edit mode
if (mode === "edit" && isLoading) { if (mode === "edit" && isLoading) {
return <div>Loading trial...</div>; return <div>Loading trial...</div>;
@@ -194,233 +314,6 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
return <div>Error loading trial: {fetchError.message}</div>; return <div>Error loading trial: {fetchError.message}</div>;
} }
// Form fields
const formFields = (
<>
<FormSection
title="Trial Setup"
description="Configure the basic details for this experimental trial."
>
<FormField>
<Label htmlFor="experimentId">Experiment *</Label>
<Select
value={form.watch("experimentId")}
onValueChange={(value) => form.setValue("experimentId", value)}
disabled={experimentsLoading || mode === "edit"}
>
<SelectTrigger
className={
form.formState.errors.experimentId ? "border-red-500" : ""
}
>
<SelectValue
placeholder={
experimentsLoading
? "Loading experiments..."
: "Select an experiment"
}
/>
</SelectTrigger>
<SelectContent>
{experimentsData?.map((experiment) => (
<SelectItem key={experiment.id} value={experiment.id}>
{experiment.name}
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.experimentId && (
<p className="text-sm text-red-600">
{form.formState.errors.experimentId.message}
</p>
)}
{mode === "edit" && (
<p className="text-muted-foreground text-xs">
Experiment cannot be changed after creation
</p>
)}
</FormField>
<FormField>
<Label htmlFor="participantId">Participant *</Label>
<Select
value={form.watch("participantId")}
onValueChange={(value) => form.setValue("participantId", value)}
disabled={participantsLoading || mode === "edit"}
>
<SelectTrigger
className={
form.formState.errors.participantId ? "border-red-500" : ""
}
>
<SelectValue
placeholder={
participantsLoading
? "Loading participants..."
: "Select a participant"
}
/>
</SelectTrigger>
<SelectContent>
{participantsData?.participants?.map((participant) => (
<SelectItem key={participant.id} value={participant.id}>
{participant.name ?? participant.participantCode} (
{participant.participantCode})
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.participantId && (
<p className="text-sm text-red-600">
{form.formState.errors.participantId.message}
</p>
)}
{mode === "edit" && (
<p className="text-muted-foreground text-xs">
Participant cannot be changed after creation
</p>
)}
</FormField>
<FormField>
<Label htmlFor="scheduledAt">Scheduled Date & Time *</Label>
<Input
id="scheduledAt"
type="datetime-local"
{...form.register("scheduledAt")}
className={
form.formState.errors.scheduledAt ? "border-red-500" : ""
}
/>
{form.formState.errors.scheduledAt && (
<p className="text-sm text-red-600">
{form.formState.errors.scheduledAt.message}
</p>
)}
<p className="text-muted-foreground text-xs">
When should this trial be conducted?
</p>
</FormField>
<FormField>
<Label htmlFor="sessionNumber">Session Number</Label>
<Input
id="sessionNumber"
type="number"
min="1"
{...form.register("sessionNumber", { valueAsNumber: true })}
placeholder="1"
className={
form.formState.errors.sessionNumber ? "border-red-500" : ""
}
/>
{form.formState.errors.sessionNumber && (
<p className="text-sm text-red-600">
{form.formState.errors.sessionNumber.message}
</p>
)}
<p className="text-muted-foreground text-xs">
Session number for this participant (for multi-session studies)
</p>
</FormField>
</FormSection>
<FormSection
title="Assignment & Notes"
description="Optional wizard assignment and trial-specific notes."
>
<FormField>
<Label htmlFor="wizardId">Assigned Wizard</Label>
<Select
value={form.watch("wizardId") ?? "none"}
onValueChange={(value) =>
form.setValue("wizardId", value === "none" ? undefined : value)
}
disabled={usersLoading}
>
<SelectTrigger>
<SelectValue
placeholder={
usersLoading
? "Loading wizards..."
: "Select a wizard (optional)"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No wizard assigned</SelectItem>
{usersData?.map(
(user: { id: string; name: string; email: string }) => (
<SelectItem key={user.id} value={user.id}>
{user.name} ({user.email})
</SelectItem>
),
)}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Optional: Assign a specific wizard to operate this trial
</p>
</FormField>
<FormField>
<Label htmlFor="notes">Trial Notes</Label>
<Textarea
id="notes"
{...form.register("notes")}
placeholder="Special instructions, conditions, or notes for this trial..."
rows={3}
className={form.formState.errors.notes ? "border-red-500" : ""}
/>
{form.formState.errors.notes && (
<p className="text-sm text-red-600">
{form.formState.errors.notes.message}
</p>
)}
<p className="text-muted-foreground text-xs">
Optional: Notes about special conditions, instructions, or context
for this trial
</p>
</FormField>
</FormSection>
</>
);
// Sidebar content
const sidebar = (
<>
<NextSteps
steps={[
{
title: "Execute Trial",
description: "Use the wizard interface to run the trial",
completed: mode === "edit",
},
{
title: "Monitor Progress",
description: "Track trial execution and data collection",
},
{
title: "Review Data",
description: "Analyze collected trial data and results",
},
{
title: "Generate Reports",
description: "Export data and create analysis reports",
},
]}
/>
<Tips
tips={[
"Schedule ahead: Allow sufficient time between trials for setup and data review.",
"Assign wizards: Pre-assign experienced wizards to complex trials.",
"Document conditions: Use notes to record any special circumstances or variations.",
"Test connectivity: Verify robot and system connections before scheduled trials.",
]}
/>
</>
);
return ( return (
<EntityForm <EntityForm
mode={mode} mode={mode}
@@ -443,14 +336,196 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
onSubmit={onSubmit} onSubmit={onSubmit}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
error={error} error={error}
onDelete={ sidebar={undefined}
mode === "edit" && trial?.status === "scheduled" ? onDelete : undefined
}
isDeleting={isDeleting}
sidebar={sidebar}
submitText={mode === "create" ? "Schedule Trial" : "Save Changes"} submitText={mode === "create" ? "Schedule Trial" : "Save Changes"}
layout="full-width"
> >
{formFields} <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{/* Left Column: Main Info (Spans 2) */}
<div className="space-y-6 md:col-span-2">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<FormField>
<Label htmlFor="experimentId">Experiment *</Label>
<Select
value={form.watch("experimentId")}
onValueChange={(value) => form.setValue("experimentId", value)}
disabled={experimentsLoading || mode === "edit"}
>
<SelectTrigger
className={
form.formState.errors.experimentId ? "border-red-500" : ""
}
>
<SelectValue
placeholder={
experimentsLoading
? "Loading experiments..."
: "Select an experiment"
}
/>
</SelectTrigger>
<SelectContent>
{experimentsData?.map((experiment) => (
<SelectItem key={experiment.id} value={experiment.id}>
{experiment.name}
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.experimentId && (
<p className="text-sm text-red-600">
{form.formState.errors.experimentId.message}
</p>
)}
{mode === "edit" && (
<p className="text-muted-foreground text-xs">
Experiment cannot be changed after creation
</p>
)}
</FormField>
<FormField>
<Label htmlFor="participantId">Participant *</Label>
<Select
value={form.watch("participantId")}
onValueChange={(value) => form.setValue("participantId", value)}
disabled={participantsLoading || mode === "edit"}
>
<SelectTrigger
className={
form.formState.errors.participantId ? "border-red-500" : ""
}
>
<SelectValue
placeholder={
participantsLoading
? "Loading participants..."
: "Select a participant"
}
/>
</SelectTrigger>
<SelectContent>
{participantsData?.participants?.map((participant) => (
<SelectItem key={participant.id} value={participant.id}>
{participant.name ?? participant.participantCode} (
{participant.participantCode})
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.participantId && (
<p className="text-sm text-red-600">
{form.formState.errors.participantId.message}
</p>
)}
{mode === "edit" && (
<p className="text-muted-foreground text-xs">
Participant cannot be changed after creation
</p>
)}
</FormField>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<FormField>
<Label htmlFor="scheduledAt">Scheduled Date & Time *</Label>
<Controller
control={form.control}
name="scheduledAt"
render={({ field }) => (
<DateTimePicker
value={field.value}
onChange={field.onChange}
/>
)}
/>
{form.formState.errors.scheduledAt && (
<p className="text-sm text-red-600">
{form.formState.errors.scheduledAt.message}
</p>
)}
<p className="text-muted-foreground text-xs">
When should this trial be conducted?
</p>
</FormField>
<FormField>
<Label htmlFor="sessionNumber">Session Number</Label>
<Input
id="sessionNumber"
type="number"
min="1"
{...form.register("sessionNumber", { valueAsNumber: true })}
placeholder="1"
className={
form.formState.errors.sessionNumber ? "border-red-500" : ""
}
/>
{form.formState.errors.sessionNumber && (
<p className="text-sm text-red-600">
{form.formState.errors.sessionNumber.message}
</p>
)}
<p className="text-muted-foreground text-xs">
Auto-incremented based on participant history
</p>
</FormField>
</div>
</div>
{/* Right Column: Assignment & Notes (Spans 1) */}
<div className="space-y-6">
<FormField>
<Label htmlFor="wizardId">Assigned Wizard</Label>
<Select
value={form.watch("wizardId") ?? "none"}
onValueChange={(value) =>
form.setValue("wizardId", value === "none" ? undefined : value)
}
disabled={usersLoading}
>
<SelectTrigger>
<SelectValue
placeholder={
usersLoading
? "Loading wizards..."
: "Select a wizard (optional)"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No wizard assigned</SelectItem>
{usersData?.map(
(user: { id: string; name: string; email: string }) => (
<SelectItem key={user.id} value={user.id}>
{user.name} ({user.email})
</SelectItem>
),
)}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Who will operate the robot?
</p>
</FormField>
<FormField>
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
{...form.register("notes")}
placeholder="Special instructions..."
rows={5}
className={form.formState.errors.notes ? "border-red-500" : ""}
/>
{form.formState.errors.notes && (
<p className="text-sm text-red-600">
{form.formState.errors.notes.message}
</p>
)}
</FormField>
</div>
</div>
</EntityForm> </EntityForm>
); );
} }

Some files were not shown because too many files have changed in this diff Show More