Merge remote-tracking branch 'old-github/main' into integrate-old-refactors-of-github

This commit is contained in:
Alireza Vaezi
2026-03-09 13:10:08 -04:00
35 changed files with 2878 additions and 688 deletions

View File

@@ -26,9 +26,8 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [currentView, setCurrentView] = useState('dashboard')
const [isExpanded, setIsExpanded] = useState(true)
const [isExpanded, setIsExpanded] = useState(false)
const [isMobileOpen, setIsMobileOpen] = useState(false)
const [isHovered, setIsHovered] = useState(false)
// Valid dashboard views
const validViews = ['dashboard', 'user-management', 'experiments', 'analytics', 'data-entry', 'vision-system', 'scheduling', 'video-library', 'profile']
@@ -53,6 +52,26 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
}
}
// Save sidebar expanded state to localStorage
const saveSidebarState = (expanded: boolean) => {
try {
localStorage.setItem('sidebar-expanded', String(expanded))
} catch (error) {
console.warn('Failed to save sidebar state to localStorage:', error)
}
}
// Get saved sidebar state from localStorage
const getSavedSidebarState = (): boolean => {
try {
const saved = localStorage.getItem('sidebar-expanded')
return saved === 'true'
} catch (error) {
console.warn('Failed to get saved sidebar state from localStorage:', error)
return false
}
}
// Check if user has access to a specific view
const hasAccessToView = (view: string): boolean => {
if (!user) return false
@@ -80,6 +99,9 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
useEffect(() => {
fetchUserProfile()
// Load saved sidebar state
const savedSidebarState = getSavedSidebarState()
setIsExpanded(savedSidebarState)
}, [])
// Restore saved view when user is loaded
@@ -144,7 +166,9 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
}
const toggleSidebar = () => {
setIsExpanded(!isExpanded)
const newState = !isExpanded
setIsExpanded(newState)
saveSidebarState(newState)
}
const toggleMobileSidebar = () => {
@@ -225,7 +249,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
)
case 'scheduling':
return (
<ErrorBoundary>
<ErrorBoundary autoRetry={true} retryDelay={2000} maxRetries={3}>
<Suspense fallback={<div className="p-6">Loading scheduling module...</div>}>
<RemoteScheduling user={user} currentRoute={currentRoute} />
</Suspense>
@@ -300,8 +324,6 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
onViewChange={handleViewChange}
isExpanded={isExpanded}
isMobileOpen={isMobileOpen}
isHovered={isHovered}
setIsHovered={setIsHovered}
/>
{/* Backdrop for mobile */}
{isMobileOpen && (
@@ -312,7 +334,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
)}
</div>
<div
className={`flex-1 transition-all duration-300 ease-in-out bg-gray-50 dark:bg-gray-900 flex flex-col min-h-0 ${isExpanded || isHovered ? "lg:ml-[290px]" : "lg:ml-[90px]"
className={`flex-1 transition-all duration-300 ease-in-out bg-gray-50 dark:bg-gray-900 flex flex-col min-h-0 ${isExpanded ? "lg:ml-[290px]" : "lg:ml-[90px]"
} ${isMobileOpen ? "ml-0" : ""}`}
>
<TopNavbar

View File

@@ -5,20 +5,61 @@ type Props = {
fallback?: ReactNode
onRetry?: () => void
showRetry?: boolean
autoRetry?: boolean
retryDelay?: number
maxRetries?: number
}
type State = { hasError: boolean }
type State = { hasError: boolean; retryCount: number }
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false }
private retryTimeoutId?: NodeJS.Timeout
state: State = { hasError: false, retryCount: 0 }
static getDerivedStateFromError() {
return { hasError: true }
}
componentDidCatch() {}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Auto-retry logic for module federation loading issues
const maxRetries = this.props.maxRetries || 3
if (this.props.autoRetry !== false && this.state.retryCount < maxRetries) {
const delay = this.props.retryDelay || 2000
this.retryTimeoutId = setTimeout(() => {
this.setState(prevState => ({
hasError: false,
retryCount: prevState.retryCount + 1
}))
if (this.props.onRetry) {
this.props.onRetry()
}
}, delay)
}
}
componentDidUpdate(prevProps: Props, prevState: State) {
// Reset retry count if error is cleared and component successfully rendered
if (prevState.hasError && !this.state.hasError && this.state.retryCount > 0) {
// Give it a moment to see if it stays error-free
setTimeout(() => {
if (!this.state.hasError) {
this.setState({ retryCount: 0 })
}
}, 1000)
}
}
componentWillUnmount() {
if (this.retryTimeoutId) {
clearTimeout(this.retryTimeoutId)
}
}
handleRetry = () => {
this.setState({ hasError: false })
if (this.retryTimeoutId) {
clearTimeout(this.retryTimeoutId)
}
this.setState({ hasError: false, retryCount: 0 })
if (this.props.onRetry) {
this.props.onRetry()
}
@@ -43,6 +84,11 @@ export class ErrorBoundary extends Component<Props, State> {
<h3 className="text-sm font-medium text-red-800">Something went wrong loading this section</h3>
<div className="mt-2 text-sm text-red-700">
<p>An error occurred while loading this component. Please try reloading it.</p>
{this.props.autoRetry !== false && this.state.retryCount < (this.props.maxRetries || 3) && (
<p className="mt-1 text-xs text-red-600">
Retrying automatically... (Attempt {this.state.retryCount + 1} of {(this.props.maxRetries || 3) + 1})
</p>
)}
</div>
{(this.props.showRetry !== false) && (
<div className="mt-4">

View File

@@ -3,7 +3,11 @@ import type { CreateExperimentRequest, UpdateExperimentRequest, ScheduleStatus,
import { experimentPhaseManagement, machineTypeManagement } from '../lib/supabase'
interface ExperimentFormProps {
<<<<<<< HEAD
initialData?: Partial<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus; completion_status: boolean }>
=======
initialData?: Partial<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus; completion_status: boolean }> & { phase_id?: string | null }
>>>>>>> old-github/main
onSubmit: (data: CreateExperimentRequest | UpdateExperimentRequest) => Promise<void>
onCancel: () => void
isEditing?: boolean
@@ -12,6 +16,7 @@ interface ExperimentFormProps {
}
export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = false, loading = false, phaseId }: ExperimentFormProps) {
<<<<<<< HEAD
const [formData, setFormData] = useState<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus; completion_status: boolean }>({
experiment_number: initialData?.experiment_number || 0,
reps_required: initialData?.reps_required || 1,
@@ -32,11 +37,46 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
phase_id: initialData?.phase_id || phaseId
})
=======
const getInitialFormState = (d: any) => ({
experiment_number: d?.experiment_number ?? 0,
reps_required: d?.reps_required ?? 1,
weight_per_repetition_lbs: d?.weight_per_repetition_lbs ?? 1,
soaking_duration_hr: d?.soaking?.soaking_duration_hr ?? d?.soaking_duration_hr ?? 0,
air_drying_time_min: d?.airdrying?.duration_minutes ?? d?.air_drying_time_min ?? 0,
plate_contact_frequency_hz: d?.cracking?.plate_contact_frequency_hz ?? d?.plate_contact_frequency_hz ?? 1,
throughput_rate_pecans_sec: d?.cracking?.throughput_rate_pecans_sec ?? d?.throughput_rate_pecans_sec ?? 1,
crush_amount_in: d?.cracking?.crush_amount_in ?? d?.crush_amount_in ?? 0,
entry_exit_height_diff_in: d?.cracking?.entry_exit_height_diff_in ?? d?.entry_exit_height_diff_in ?? 0,
motor_speed_hz: d?.cracking?.motor_speed_hz ?? d?.motor_speed_hz ?? 1,
jig_displacement_inches: d?.cracking?.jig_displacement_inches ?? d?.jig_displacement_inches ?? 0,
spring_stiffness_nm: d?.cracking?.spring_stiffness_nm ?? d?.spring_stiffness_nm ?? 1,
schedule_status: d?.schedule_status ?? 'pending schedule',
results_status: d?.results_status ?? 'valid',
completion_status: d?.completion_status ?? false,
phase_id: d?.phase_id ?? phaseId,
ring_gap_inches: d?.shelling?.ring_gap_inches ?? d?.ring_gap_inches ?? null,
drum_rpm: d?.shelling?.drum_rpm ?? d?.drum_rpm ?? null
})
const [formData, setFormData] = useState<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus; completion_status: boolean }>(() => getInitialFormState(initialData))
>>>>>>> old-github/main
const [errors, setErrors] = useState<Record<string, string>>({})
const [phase, setPhase] = useState<ExperimentPhase | null>(null)
const [crackingMachine, setCrackingMachine] = useState<MachineType | null>(null)
const [metaLoading, setMetaLoading] = useState<boolean>(false)
<<<<<<< HEAD
=======
// When initialData loads with phase config (edit mode), sync form state
useEffect(() => {
if ((initialData as any)?.id) {
setFormData(prev => ({ ...prev, ...getInitialFormState(initialData) }))
}
}, [initialData])
>>>>>>> old-github/main
useEffect(() => {
const loadMeta = async () => {
if (!phaseId) return
@@ -76,11 +116,19 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
}
<<<<<<< HEAD
if (formData.soaking_duration_hr < 0) {
newErrors.soaking_duration_hr = 'Soaking duration cannot be negative'
}
if (formData.air_drying_time_min < 0) {
=======
if ((formData.soaking_duration_hr ?? 0) < 0) {
newErrors.soaking_duration_hr = 'Soaking duration cannot be negative'
}
if ((formData.air_drying_time_min ?? 0) < 0) {
>>>>>>> old-github/main
newErrors.air_drying_time_min = 'Air drying time cannot be negative'
}
@@ -93,7 +141,11 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
if (!formData.throughput_rate_pecans_sec || formData.throughput_rate_pecans_sec <= 0) {
newErrors.throughput_rate_pecans_sec = 'Throughput rate must be positive'
}
<<<<<<< HEAD
if (formData.crush_amount_in < 0) {
=======
if ((formData.crush_amount_in ?? 0) < 0) {
>>>>>>> old-github/main
newErrors.crush_amount_in = 'Crush amount cannot be negative'
}
}
@@ -110,6 +162,19 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
}
}
<<<<<<< HEAD
=======
// Shelling: if provided, must be positive
if (phase?.has_shelling) {
if (formData.ring_gap_inches != null && (typeof formData.ring_gap_inches !== 'number' || formData.ring_gap_inches <= 0)) {
newErrors.ring_gap_inches = 'Ring gap must be positive'
}
if (formData.drum_rpm != null && (typeof formData.drum_rpm !== 'number' || formData.drum_rpm <= 0)) {
newErrors.drum_rpm = 'Drum RPM must be positive'
}
}
>>>>>>> old-github/main
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
@@ -122,14 +187,33 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
}
try {
<<<<<<< HEAD
// Prepare data for submission
=======
// Prepare data: include all phase params so they are stored in experiment_soaking, experiment_airdrying, experiment_cracking, experiment_shelling
>>>>>>> old-github/main
const submitData = isEditing ? formData : {
experiment_number: formData.experiment_number,
reps_required: formData.reps_required,
weight_per_repetition_lbs: formData.weight_per_repetition_lbs,
results_status: formData.results_status,
completion_status: formData.completion_status,
<<<<<<< HEAD
phase_id: formData.phase_id
=======
phase_id: formData.phase_id,
soaking_duration_hr: formData.soaking_duration_hr,
air_drying_time_min: formData.air_drying_time_min,
plate_contact_frequency_hz: formData.plate_contact_frequency_hz,
throughput_rate_pecans_sec: formData.throughput_rate_pecans_sec,
crush_amount_in: formData.crush_amount_in,
entry_exit_height_diff_in: formData.entry_exit_height_diff_in,
motor_speed_hz: (formData as any).motor_speed_hz,
jig_displacement_inches: (formData as any).jig_displacement_inches,
spring_stiffness_nm: (formData as any).spring_stiffness_nm,
ring_gap_inches: formData.ring_gap_inches ?? undefined,
drum_rpm: formData.drum_rpm ?? undefined
>>>>>>> old-github/main
}
await onSubmit(submitData)
@@ -138,7 +222,11 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
}
}
<<<<<<< HEAD
const handleInputChange = (field: keyof typeof formData, value: string | number | boolean) => {
=======
const handleInputChange = (field: keyof typeof formData, value: string | number | boolean | null | undefined) => {
>>>>>>> old-github/main
setFormData(prev => ({
...prev,
[field]: value
@@ -441,6 +529,7 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
<h3 className="text-lg font-medium text-gray-900 mb-4">Shelling</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<<<<<<< HEAD
<label className="block text-sm font-medium text-gray-700 mb-2">
Shelling Start Offset (minutes)
</label>
@@ -453,6 +542,42 @@ export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = fa
min="0"
step="1"
/>
=======
<label htmlFor="ring_gap_inches" className="block text-sm font-medium text-gray-700 mb-2">
Ring gap (inches)
</label>
<input
type="number"
id="ring_gap_inches"
value={formData.ring_gap_inches ?? ''}
onChange={(e) => handleInputChange('ring_gap_inches' as any, e.target.value === '' ? null : parseFloat(e.target.value))}
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.ring_gap_inches ? 'border-red-300' : 'border-gray-300'}`}
placeholder="e.g. 0.25"
min="0"
step="0.01"
/>
{errors.ring_gap_inches && (
<p className="mt-1 text-sm text-red-600">{errors.ring_gap_inches}</p>
)}
</div>
<div>
<label htmlFor="drum_rpm" className="block text-sm font-medium text-gray-700 mb-2">
Drum RPM
</label>
<input
type="number"
id="drum_rpm"
value={formData.drum_rpm ?? ''}
onChange={(e) => handleInputChange('drum_rpm' as any, e.target.value === '' ? null : parseInt(e.target.value, 10))}
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.drum_rpm ? 'border-red-300' : 'border-gray-300'}`}
placeholder="e.g. 300"
min="1"
step="1"
/>
{errors.drum_rpm && (
<p className="mt-1 text-sm text-red-600">{errors.drum_rpm}</p>
)}
>>>>>>> old-github/main
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { ExperimentForm } from './ExperimentForm'
import { experimentManagement } from '../lib/supabase'
import type { Experiment, CreateExperimentRequest, UpdateExperimentRequest } from '../lib/supabase'
@@ -13,9 +13,20 @@ interface ExperimentModalProps {
export function ExperimentModal({ experiment, onClose, onExperimentSaved, phaseId }: ExperimentModalProps) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [initialData, setInitialData] = useState<Experiment | (Experiment & { soaking?: any; airdrying?: any; cracking?: any; shelling?: any }) | undefined>(experiment ?? undefined)
const isEditing = !!experiment
useEffect(() => {
if (experiment) {
experimentManagement.getExperimentWithPhaseConfig(experiment.id)
.then((data) => setInitialData(data ?? experiment))
.catch(() => setInitialData(experiment))
} else {
setInitialData(undefined)
}
}, [experiment?.id])
const handleSubmit = async (data: CreateExperimentRequest | UpdateExperimentRequest) => {
setError(null)
setLoading(true)
@@ -24,22 +35,24 @@ export function ExperimentModal({ experiment, onClose, onExperimentSaved, phaseI
let savedExperiment: Experiment
if (isEditing && experiment) {
// Check if experiment number is unique (excluding current experiment)
// Check if experiment number is unique within this phase (excluding current experiment)
if ('experiment_number' in data && data.experiment_number !== undefined && data.experiment_number !== experiment.experiment_number) {
const isUnique = await experimentManagement.isExperimentNumberUnique(data.experiment_number, experiment.id)
const phaseIdToCheck = data.phase_id ?? experiment.phase_id ?? phaseId
const isUnique = await experimentManagement.isExperimentNumberUnique(data.experiment_number, phaseIdToCheck ?? undefined, experiment.id)
if (!isUnique) {
setError('Experiment number already exists. Please choose a different number.')
setError('Experiment number already exists in this phase. Please choose a different number.')
return
}
}
savedExperiment = await experimentManagement.updateExperiment(experiment.id, data)
} else {
// Check if experiment number is unique for new experiments
// Check if experiment number is unique within this phase for new experiments
const createData = data as CreateExperimentRequest
const isUnique = await experimentManagement.isExperimentNumberUnique(createData.experiment_number)
const phaseIdToCheck = createData.phase_id ?? phaseId
const isUnique = await experimentManagement.isExperimentNumberUnique(createData.experiment_number, phaseIdToCheck ?? undefined)
if (!isUnique) {
setError('Experiment number already exists. Please choose a different number.')
setError('Experiment number already exists in this phase. Please choose a different number.')
return
}
@@ -115,7 +128,7 @@ export function ExperimentModal({ experiment, onClose, onExperimentSaved, phaseI
{/* Form */}
<ExperimentForm
initialData={experiment}
initialData={initialData ? { ...initialData, phase_id: initialData.phase_id ?? undefined } : undefined}
onSubmit={handleSubmit}
onCancel={handleCancel}
isEditing={isEditing}

View File

@@ -31,8 +31,8 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
setPhases(phasesData)
setCurrentUser(userData)
} catch (err: any) {
setError(err.message || 'Failed to load experiment phases')
console.error('Load experiment phases error:', err)
setError(err.message || 'Failed to load experiment books')
console.error('Load experiment books error:', err)
} finally {
setLoading(false)
}
@@ -61,16 +61,16 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
<div className="mb-8">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Experiment Phases</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">Select an experiment phase to view and manage its experiments</p>
<p className="mt-2 text-gray-600 dark:text-gray-400">Experiment phases help organize experiments into logical groups for easier navigation and management.</p>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Experiment Books</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">Select an experiment book to view and manage its experiments</p>
<p className="mt-2 text-gray-600 dark:text-gray-400">Experiment books help organize experiments into logical groups for easier navigation and management.</p>
</div>
{canManagePhases && (
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
New Phase
New Book
</button>
)}
</div>
@@ -162,9 +162,9 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
<svg className="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">No experiment phases found</h3>
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">No experiment books found</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Get started by creating your first experiment phase.
Get started by creating your first experiment book.
</p>
{canManagePhases && (
<div className="mt-6">
@@ -172,7 +172,7 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Create First Phase
Create First Book
</button>
</div>
)}

View File

@@ -193,7 +193,7 @@ export function PhaseExperiments({ phase, onBack }: PhaseExperimentsProps) {
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Phases
Back to Books
</button>
</div>
@@ -203,7 +203,7 @@ export function PhaseExperiments({ phase, onBack }: PhaseExperimentsProps) {
{phase.description && (
<p className="mt-2 text-gray-600">{phase.description}</p>
)}
<p className="mt-2 text-gray-600">Manage experiments within this phase</p>
<p className="mt-2 text-gray-600">Manage experiments within this book</p>
</div>
{canManageExperiments && (
<button
@@ -417,9 +417,9 @@ export function PhaseExperiments({ phase, onBack }: PhaseExperimentsProps) {
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No experiments found in this phase</h3>
<h3 className="mt-2 text-sm font-medium text-gray-900">No experiments found in this book</h3>
<p className="mt-1 text-sm text-gray-500">
Get started by creating your first experiment in this phase.
Get started by creating your first experiment in this book.
</p>
{canManageExperiments && (
<div className="mt-6">

View File

@@ -147,7 +147,11 @@ export function PhaseForm({ onSubmit, onCancel, loading = false }: PhaseFormProp
onChange={(e) => handleInputChange('description', e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
<<<<<<< HEAD
placeholder="Optional description of this experiment phase"
=======
placeholder="Optional description of this experiment book"
>>>>>>> old-github/main
disabled={loading}
/>
</div>

View File

@@ -21,7 +21,7 @@ export function PhaseModal({ onClose, onPhaseCreated }: PhaseModalProps) {
onPhaseCreated(newPhase)
onClose()
} catch (err: any) {
setError(err.message || 'Failed to create experiment phase')
setError(err.message || 'Failed to create experiment book')
console.error('Create phase error:', err)
} finally {
setLoading(false)
@@ -35,7 +35,7 @@ export function PhaseModal({ onClose, onPhaseCreated }: PhaseModalProps) {
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-medium text-gray-900">
Create New Experiment Phase
Create New Experiment Book
</h3>
<button
onClick={onClose}

View File

@@ -7,8 +7,11 @@ interface SidebarProps {
onViewChange: (view: string) => void
isExpanded?: boolean
isMobileOpen?: boolean
<<<<<<< HEAD
isHovered?: boolean
setIsHovered?: (hovered: boolean) => void
=======
>>>>>>> old-github/main
}
interface MenuItem {
@@ -23,10 +26,15 @@ export function Sidebar({
user,
currentView,
onViewChange,
<<<<<<< HEAD
isExpanded = true,
isMobileOpen = false,
isHovered = false,
setIsHovered
=======
isExpanded = false,
isMobileOpen = false
>>>>>>> old-github/main
}: SidebarProps) {
const [openSubmenu, setOpenSubmenu] = useState<number | null>(null)
const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>({})
@@ -170,7 +178,11 @@ export function Sidebar({
className={`menu-item group ${openSubmenu === index
? "menu-item-active"
: "menu-item-inactive"
<<<<<<< HEAD
} cursor-pointer ${!isExpanded && !isHovered
=======
} cursor-pointer ${!isExpanded
>>>>>>> old-github/main
? "lg:justify-center"
: "lg:justify-start"
}`}
@@ -183,10 +195,17 @@ export function Sidebar({
>
{nav.icon}
</span>
<<<<<<< HEAD
{(isExpanded || isHovered || isMobileOpen) && (
<span className="menu-item-text">{nav.name}</span>
)}
{(isExpanded || isHovered || isMobileOpen) && (
=======
{(isExpanded || isMobileOpen) && (
<span className="menu-item-text">{nav.name}</span>
)}
{(isExpanded || isMobileOpen) && (
>>>>>>> old-github/main
<svg
className={`ml-auto w-5 h-5 transition-transform duration-200 ${openSubmenu === index
? "rotate-180 text-brand-500"
@@ -214,12 +233,20 @@ export function Sidebar({
>
{nav.icon}
</span>
<<<<<<< HEAD
{(isExpanded || isHovered || isMobileOpen) && (
=======
{(isExpanded || isMobileOpen) && (
>>>>>>> old-github/main
<span className="menu-item-text">{nav.name}</span>
)}
</button>
)}
<<<<<<< HEAD
{nav.subItems && (isExpanded || isHovered || isMobileOpen) && (
=======
{nav.subItems && (isExpanded || isMobileOpen) && (
>>>>>>> old-github/main
<div
ref={(el) => {
subMenuRefs.current[`submenu-${index}`] = el
@@ -265,6 +292,7 @@ export function Sidebar({
className={`fixed mt-16 flex flex-col lg:mt-0 top-0 px-5 left-0 bg-white dark:bg-gray-900 dark:border-gray-800 text-gray-900 h-screen transition-all duration-300 ease-in-out z-50 border-r border-gray-200
${isExpanded || isMobileOpen
? "w-[290px]"
<<<<<<< HEAD
: isHovered
? "w-[290px]"
: "w-[90px]"
@@ -280,6 +308,19 @@ export function Sidebar({
>
<div>
{isExpanded || isHovered || isMobileOpen ? (
=======
: "w-[90px]"
}
${isMobileOpen ? "translate-x-0" : "-translate-x-full"}
lg:translate-x-0`}
>
<div
className={`py-8 flex ${!isExpanded ? "lg:justify-center" : "justify-start"
}`}
>
<div>
{isExpanded || isMobileOpen ? (
>>>>>>> old-github/main
<>
<h1 className="text-xl font-bold text-gray-800 dark:text-white/90">Pecan Experiments</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">Research Dashboard</p>
@@ -297,12 +338,20 @@ export function Sidebar({
<div className="flex flex-col gap-4">
<div>
<h2
<<<<<<< HEAD
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered
=======
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded
>>>>>>> old-github/main
? "lg:justify-center"
: "justify-start"
}`}
>
<<<<<<< HEAD
{isExpanded || isHovered || isMobileOpen ? (
=======
{isExpanded || isMobileOpen ? (
>>>>>>> old-github/main
"Menu"
) : (
<svg className="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -60,6 +60,8 @@ export interface Experiment {
airdrying_id?: string | null
cracking_id?: string | null
shelling_id?: string | null
ring_gap_inches?: number | null
drum_rpm?: number | null
created_at: string
updated_at: string
created_by: string
@@ -170,6 +172,51 @@ export interface UpdateExperimentPhaseRequest {
has_shelling?: boolean
}
// Experiment-level phase config (one row per experiment per phase; stored in experiment_soaking, experiment_airdrying, experiment_cracking, experiment_shelling)
export interface ExperimentSoakingConfig {
id: string
experiment_id: string
soaking_duration_hr: number
created_at: string
updated_at: string
created_by: string
}
export interface ExperimentAirdryingConfig {
id: string
experiment_id: string
duration_minutes: number
created_at: string
updated_at: string
created_by: string
}
export interface ExperimentCrackingConfig {
id: string
experiment_id: string
machine_type_id: string
plate_contact_frequency_hz?: number | null
throughput_rate_pecans_sec?: number | null
crush_amount_in?: number | null
entry_exit_height_diff_in?: number | null
motor_speed_hz?: number | null
jig_displacement_inches?: number | null
spring_stiffness_nm?: number | null
created_at: string
updated_at: string
created_by: string
}
export interface ExperimentShellingConfig {
id: string
experiment_id: string
ring_gap_inches?: number | null
drum_rpm?: number | null
created_at: string
updated_at: string
created_by: string
}
export interface CreateExperimentRequest {
experiment_number: number
reps_required: number
@@ -177,6 +224,19 @@ export interface CreateExperimentRequest {
results_status?: ResultsStatus
completion_status?: boolean
phase_id?: string
// Phase config (stored in experiment_soaking, experiment_airdrying, experiment_cracking, experiment_shelling)
soaking_duration_hr?: number
air_drying_time_min?: number
// Cracking: machine_type comes from book; params below are JC or Meyer specific
plate_contact_frequency_hz?: number
throughput_rate_pecans_sec?: number
crush_amount_in?: number
entry_exit_height_diff_in?: number
motor_speed_hz?: number
jig_displacement_inches?: number
spring_stiffness_nm?: number
ring_gap_inches?: number | null
drum_rpm?: number | null
}
export interface UpdateExperimentRequest {
@@ -186,6 +246,17 @@ export interface UpdateExperimentRequest {
results_status?: ResultsStatus
completion_status?: boolean
phase_id?: string
soaking_duration_hr?: number
air_drying_time_min?: number
plate_contact_frequency_hz?: number
throughput_rate_pecans_sec?: number
crush_amount_in?: number
entry_exit_height_diff_in?: number
motor_speed_hz?: number
jig_displacement_inches?: number
spring_stiffness_nm?: number
ring_gap_inches?: number | null
drum_rpm?: number | null
}
export interface CreateRepetitionRequest {
@@ -614,12 +685,12 @@ export const userManagement = {
}
}
// Experiment phase management utility functions
// Experiment book management (table: experiment_books)
export const experimentPhaseManagement = {
// Get all experiment phases
// Get all experiment books
async getAllExperimentPhases(): Promise<ExperimentPhase[]> {
const { data, error } = await supabase
.from('experiment_phases')
.from('experiment_books')
.select('*')
.order('created_at', { ascending: false })
@@ -627,10 +698,10 @@ export const experimentPhaseManagement = {
return data
},
// Get experiment phase by ID
// Get experiment book by ID
async getExperimentPhaseById(id: string): Promise<ExperimentPhase | null> {
const { data, error } = await supabase
.from('experiment_phases')
.from('experiment_books')
.select('*')
.eq('id', id)
.single()
@@ -642,13 +713,13 @@ export const experimentPhaseManagement = {
return data
},
// Create a new experiment phase
// Create a new experiment book
async createExperimentPhase(phaseData: CreateExperimentPhaseRequest): Promise<ExperimentPhase> {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
const { data, error } = await supabase
.from('experiment_phases')
.from('experiment_books')
.insert({
...phaseData,
created_by: user.id
@@ -660,10 +731,10 @@ export const experimentPhaseManagement = {
return data
},
// Update an experiment phase
// Update an experiment book
async updateExperimentPhase(id: string, updates: UpdateExperimentPhaseRequest): Promise<ExperimentPhase> {
const { data, error } = await supabase
.from('experiment_phases')
.from('experiment_books')
.update(updates)
.eq('id', id)
.select()
@@ -673,10 +744,10 @@ export const experimentPhaseManagement = {
return data
},
// Delete an experiment phase
// Delete an experiment book
async deleteExperimentPhase(id: string): Promise<void> {
const { error } = await supabase
.from('experiment_phases')
.from('experiment_books')
.delete()
.eq('id', id)
@@ -724,33 +795,170 @@ export const experimentManagement = {
return data
},
// Create a new experiment
// Get experiment with its phase config (soaking, airdrying, cracking, shelling) for edit form
async getExperimentWithPhaseConfig(id: string): Promise<(Experiment & {
soaking?: ExperimentSoakingConfig | null
airdrying?: ExperimentAirdryingConfig | null
cracking?: ExperimentCrackingConfig | null
shelling?: ExperimentShellingConfig | null
}) | null> {
const experiment = await this.getExperimentById(id)
if (!experiment) return null
const [soakingRes, airdryingRes, crackingRes, shellingRes] = await Promise.all([
supabase.from('experiment_soaking').select('*').eq('experiment_id', id).maybeSingle(),
supabase.from('experiment_airdrying').select('*').eq('experiment_id', id).maybeSingle(),
supabase.from('experiment_cracking').select('*').eq('experiment_id', id).maybeSingle(),
supabase.from('experiment_shelling').select('*').eq('experiment_id', id).maybeSingle()
])
if (soakingRes.error) throw soakingRes.error
if (airdryingRes.error) throw airdryingRes.error
if (crackingRes.error) throw crackingRes.error
if (shellingRes.error) throw shellingRes.error
return {
...experiment,
soaking: soakingRes.data ?? null,
airdrying: airdryingRes.data ?? null,
cracking: crackingRes.data ?? null,
shelling: shellingRes.data ?? null
}
},
// Create a new experiment and its phase config rows (experiment_soaking, experiment_airdrying, experiment_cracking, experiment_shelling)
async createExperiment(experimentData: CreateExperimentRequest): Promise<Experiment> {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
const { data, error } = await supabase
const phaseId = experimentData.phase_id
const corePayload = {
experiment_number: experimentData.experiment_number,
reps_required: experimentData.reps_required,
weight_per_repetition_lbs: experimentData.weight_per_repetition_lbs,
results_status: experimentData.results_status ?? 'valid',
completion_status: experimentData.completion_status ?? false,
phase_id: phaseId,
created_by: user.id
}
// phase_id required for phase configs
if (!phaseId) {
const { data, error } = await supabase.from('experiments').insert(corePayload).select().single()
if (error) throw error
return data
}
const { data: experiment, error } = await supabase
.from('experiments')
.insert({
...experimentData,
created_by: user.id
})
.insert(corePayload)
.select()
.single()
if (error) throw error
return data
const book = await experimentPhaseManagement.getExperimentPhaseById(phaseId)
if (!book) return experiment
if (book.has_soaking && experimentData.soaking_duration_hr != null) {
await supabase.from('experiment_soaking').insert({
experiment_id: experiment.id,
soaking_duration_hr: experimentData.soaking_duration_hr,
created_by: user.id
})
}
if (book.has_airdrying && experimentData.air_drying_time_min != null) {
await supabase.from('experiment_airdrying').insert({
experiment_id: experiment.id,
duration_minutes: experimentData.air_drying_time_min,
created_by: user.id
})
}
if (book.has_cracking && book.cracking_machine_type_id) {
const crackPayload: Record<string, unknown> = {
experiment_id: experiment.id,
machine_type_id: book.cracking_machine_type_id,
created_by: user.id
}
if (experimentData.plate_contact_frequency_hz != null) crackPayload.plate_contact_frequency_hz = experimentData.plate_contact_frequency_hz
if (experimentData.throughput_rate_pecans_sec != null) crackPayload.throughput_rate_pecans_sec = experimentData.throughput_rate_pecans_sec
if (experimentData.crush_amount_in != null) crackPayload.crush_amount_in = experimentData.crush_amount_in
if (experimentData.entry_exit_height_diff_in != null) crackPayload.entry_exit_height_diff_in = experimentData.entry_exit_height_diff_in
if (experimentData.motor_speed_hz != null) crackPayload.motor_speed_hz = experimentData.motor_speed_hz
if (experimentData.jig_displacement_inches != null) crackPayload.jig_displacement_inches = experimentData.jig_displacement_inches
if (experimentData.spring_stiffness_nm != null) crackPayload.spring_stiffness_nm = experimentData.spring_stiffness_nm
await supabase.from('experiment_cracking').insert(crackPayload)
}
if (book.has_shelling && (experimentData.ring_gap_inches != null || experimentData.drum_rpm != null)) {
await supabase.from('experiment_shelling').insert({
experiment_id: experiment.id,
ring_gap_inches: experimentData.ring_gap_inches ?? null,
drum_rpm: experimentData.drum_rpm ?? null,
created_by: user.id
})
}
return experiment
},
// Update an experiment
// Update an experiment and upsert its phase config rows
async updateExperiment(id: string, updates: UpdateExperimentRequest): Promise<Experiment> {
const { data, error } = await supabase
.from('experiments')
.update(updates)
.eq('id', id)
.select()
.single()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
const coreKeys = ['experiment_number', 'reps_required', 'weight_per_repetition_lbs', 'results_status', 'completion_status', 'phase_id'] as const
const coreUpdates: Partial<UpdateExperimentRequest> = {}
for (const k of coreKeys) {
if (updates[k] !== undefined) coreUpdates[k] = updates[k]
}
if (Object.keys(coreUpdates).length > 0) {
const { data, error } = await supabase.from('experiments').update(coreUpdates).eq('id', id).select().single()
if (error) throw error
}
if (updates.soaking_duration_hr !== undefined) {
const { data: existing } = await supabase.from('experiment_soaking').select('id').eq('experiment_id', id).maybeSingle()
if (existing) {
await supabase.from('experiment_soaking').update({ soaking_duration_hr: updates.soaking_duration_hr, updated_at: new Date().toISOString() }).eq('experiment_id', id)
} else {
await supabase.from('experiment_soaking').insert({ experiment_id: id, soaking_duration_hr: updates.soaking_duration_hr, created_by: user.id })
}
}
if (updates.air_drying_time_min !== undefined) {
const { data: existing } = await supabase.from('experiment_airdrying').select('id').eq('experiment_id', id).maybeSingle()
if (existing) {
await supabase.from('experiment_airdrying').update({ duration_minutes: updates.air_drying_time_min, updated_at: new Date().toISOString() }).eq('experiment_id', id)
} else {
await supabase.from('experiment_airdrying').insert({ experiment_id: id, duration_minutes: updates.air_drying_time_min, created_by: user.id })
}
}
const crackKeys = ['plate_contact_frequency_hz', 'throughput_rate_pecans_sec', 'crush_amount_in', 'entry_exit_height_diff_in', 'motor_speed_hz', 'jig_displacement_inches', 'spring_stiffness_nm'] as const
const hasCrackUpdates = crackKeys.some(k => updates[k] !== undefined)
if (hasCrackUpdates) {
const { data: existing } = await supabase.from('experiment_cracking').select('id').eq('experiment_id', id).maybeSingle()
const crackPayload: Record<string, unknown> = {}
crackKeys.forEach(k => { if (updates[k] !== undefined) crackPayload[k] = updates[k] })
if (Object.keys(crackPayload).length > 0) {
if (existing) {
await supabase.from('experiment_cracking').update({ ...crackPayload, updated_at: new Date().toISOString() }).eq('experiment_id', id)
} else {
const exp = await this.getExperimentById(id)
const book = exp?.phase_id ? await experimentPhaseManagement.getExperimentPhaseById(exp.phase_id) : null
if (book?.has_cracking && book.cracking_machine_type_id) {
await supabase.from('experiment_cracking').insert({ experiment_id: id, machine_type_id: book.cracking_machine_type_id, ...crackPayload, created_by: user.id })
}
}
}
}
if (updates.ring_gap_inches !== undefined || updates.drum_rpm !== undefined) {
const { data: existing } = await supabase.from('experiment_shelling').select('id').eq('experiment_id', id).maybeSingle()
const shellPayload = { ring_gap_inches: updates.ring_gap_inches ?? null, drum_rpm: updates.drum_rpm ?? null }
if (existing) {
await supabase.from('experiment_shelling').update({ ...shellPayload, updated_at: new Date().toISOString() }).eq('experiment_id', id)
} else {
await supabase.from('experiment_shelling').insert({ experiment_id: id, ...shellPayload, created_by: user.id })
}
}
const { data, error } = await supabase.from('experiments').select('*').eq('id', id).single()
if (error) throw error
return data
},
@@ -793,13 +1001,16 @@ export const experimentManagement = {
// Check if experiment number is unique
async isExperimentNumberUnique(experimentNumber: number, excludeId?: string): Promise<boolean> {
// Check if experiment number is unique within the same phase (experiment_number + phase_id must be unique)
async isExperimentNumberUnique(experimentNumber: number, phaseId?: string, excludeId?: string): Promise<boolean> {
let query = supabase
.from('experiments')
.select('id')
.eq('experiment_number', experimentNumber)
if (phaseId) {
query = query.eq('phase_id', phaseId)
}
if (excludeId) {
query = query.neq('id', excludeId)
}