@@ -214,11 +234,11 @@ export function Experiments() {
Experiment Parameters
- Schedule Status
+ Repetitions Status
|
{canManageExperiments && (
- Scheduled Date/Time
+ Manage Repetitions
|
)}
@@ -258,30 +278,76 @@ export function Experiments() {
|
-
- {experiment.schedule_status}
-
+ {(() => {
+ const repetitions = experimentRepetitions[experiment.id] || []
+ const summary = getRepetitionStatusSummary(repetitions)
+ return (
+
+
+ {summary.total} total • {summary.scheduled} scheduled • {summary.pending} pending
+
+
+ {summary.scheduled > 0 && (
+
+ {summary.scheduled} scheduled
+
+ )}
+ {summary.pending > 0 && (
+
+ {summary.pending} pending
+
+ )}
+
+
+ )
+ })()}
|
{canManageExperiments && (
-
-
- {experiment.scheduled_date && (
-
- {new Date(experiment.scheduled_date).toLocaleString()}
-
- )}
+
+ {(() => {
+ const repetitions = experimentRepetitions[experiment.id] || []
+ return repetitions.map((repetition) => (
+
+ Rep #{repetition.repetition_number}
+
+
+ {repetition.schedule_status === 'pending schedule' ? 'Pending' : repetition.schedule_status}
+
+
+
+
+ ))
+ })()}
+ {(() => {
+ const repetitions = experimentRepetitions[experiment.id] || []
+ const missingReps = experiment.reps_required - repetitions.length
+ if (missingReps > 0) {
+ return (
+
+ )
+ }
+ return null
+ })()}
|
)}
@@ -292,8 +358,8 @@ export function Experiments() {
{experiment.completion_status ? 'Completed' : 'In Progress'}
@@ -340,11 +406,9 @@ export function Experiments() {
No experiments found
- {filterStatus === 'all'
- ? 'Get started by creating your first experiment.'
- : `No experiments with status "${filterStatus}".`}
+ Get started by creating your first experiment.
- {canManageExperiments && filterStatus === 'all' && (
+ {canManageExperiments && (
)}
- {/* Schedule Modal */}
- {showScheduleModal && schedulingExperiment && (
- setShowScheduleModal(false)}
- onScheduleUpdated={handleScheduleUpdated}
+ {/* Repetition Schedule Modal */}
+ {showRepetitionScheduleModal && schedulingRepetition && (
+ setShowRepetitionScheduleModal(false)}
+ onScheduleUpdated={handleRepetitionScheduleUpdated}
/>
)}
diff --git a/src/components/PhaseDataEntry.tsx b/src/components/PhaseDataEntry.tsx
index 1cebbfb..7782f64 100644
--- a/src/components/PhaseDataEntry.tsx
+++ b/src/components/PhaseDataEntry.tsx
@@ -1,31 +1,62 @@
import { useState, useEffect, useCallback } from 'react'
-import { dataEntryManagement, type Experiment, type ExperimentDataEntry, type ExperimentPhase, type ExperimentPhaseData } from '../lib/supabase'
+import { phaseDraftManagement, type Experiment, type ExperimentPhaseDraft, type ExperimentPhase, type ExperimentPhaseData, type ExperimentRepetition, type User } from '../lib/supabase'
+import { PhaseDraftManager } from './PhaseDraftManager'
interface PhaseDataEntryProps {
experiment: Experiment
- dataEntry: ExperimentDataEntry
+ repetition: ExperimentRepetition
phase: ExperimentPhase
+ currentUser: User
onBack: () => void
onDataSaved: () => void
}
-export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSaved }: PhaseDataEntryProps) {
+export function PhaseDataEntry({ experiment, repetition, phase, currentUser, onBack, onDataSaved }: PhaseDataEntryProps) {
+ const [selectedDraft, setSelectedDraft] = useState(null)
const [phaseData, setPhaseData] = useState>({})
const [diameterMeasurements, setDiameterMeasurements] = useState(Array(10).fill(0))
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [lastSaved, setLastSaved] = useState(null)
+ const [showDraftManager, setShowDraftManager] = useState(false)
// Auto-save interval (30 seconds)
const AUTO_SAVE_INTERVAL = 30000
- const loadPhaseData = useCallback(async () => {
+ useEffect(() => {
+ loadUserDrafts()
+ }, [repetition.id, phase])
+
+ const loadUserDrafts = async () => {
try {
setLoading(true)
setError(null)
- const existingData = await dataEntryManagement.getPhaseData(dataEntry.id, phase)
+ const drafts = await phaseDraftManagement.getUserPhaseDraftsForPhase(repetition.id, phase)
+
+ // Auto-select the most recent draft or show draft manager if none exist
+ if (drafts.length > 0) {
+ const mostRecentDraft = drafts[0] // Already sorted by created_at desc
+ setSelectedDraft(mostRecentDraft)
+ await loadPhaseDataForDraft(mostRecentDraft)
+ } else {
+ setShowDraftManager(true)
+ }
+ } catch (err: unknown) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to load drafts'
+ setError(errorMessage)
+ console.error('Load drafts error:', err)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const loadPhaseDataForDraft = async (draft: ExperimentPhaseDraft) => {
+ try {
+ setError(null)
+
+ const existingData = await phaseDraftManagement.getPhaseDataForDraft(draft.id)
if (existingData) {
setPhaseData(existingData)
@@ -43,33 +74,32 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
} else {
// Initialize empty phase data
setPhaseData({
- data_entry_id: dataEntry.id,
+ phase_draft_id: draft.id,
phase_name: phase
})
+ setDiameterMeasurements(Array(10).fill(0))
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load phase data'
setError(errorMessage)
console.error('Load phase data error:', err)
- } finally {
- setLoading(false)
}
- }, [dataEntry.id, phase])
+ }
const autoSave = useCallback(async () => {
- if (dataEntry.status === 'submitted') return // Don't auto-save submitted entries
+ if (!selectedDraft || selectedDraft.status === 'submitted') return // Don't auto-save submitted drafts
try {
- await dataEntryManagement.autoSaveDraft(dataEntry.id, phase, phaseData)
+ await phaseDraftManagement.autoSaveDraft(selectedDraft.id, phaseData)
// Save diameter measurements if this is air-drying phase and we have measurements
if (phase === 'air-drying' && phaseData.id && diameterMeasurements.some(m => m > 0)) {
const validMeasurements = diameterMeasurements.filter(m => m > 0)
if (validMeasurements.length > 0) {
- await dataEntryManagement.saveDiameterMeasurements(phaseData.id, diameterMeasurements)
+ await phaseDraftManagement.saveDiameterMeasurements(phaseData.id, diameterMeasurements)
// Update average diameter
- const avgDiameter = dataEntryManagement.calculateAverageDiameter(validMeasurements)
+ const avgDiameter = phaseDraftManagement.calculateAverageDiameter(validMeasurements)
setPhaseData(prev => ({ ...prev, avg_pecan_diameter_in: avgDiameter }))
}
}
@@ -78,22 +108,18 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
} catch (error) {
console.warn('Auto-save failed:', error)
}
- }, [dataEntry.id, dataEntry.status, phase, phaseData, diameterMeasurements])
-
- useEffect(() => {
- loadPhaseData()
- }, [loadPhaseData])
+ }, [selectedDraft, phase, phaseData, diameterMeasurements])
// Auto-save effect
useEffect(() => {
- if (!loading && phaseData.id) {
+ if (!loading && selectedDraft && phaseData.phase_draft_id) {
const interval = setInterval(() => {
autoSave()
}, AUTO_SAVE_INTERVAL)
return () => clearInterval(interval)
}
- }, [phaseData, diameterMeasurements, loading, autoSave])
+ }, [phaseData, diameterMeasurements, loading, autoSave, selectedDraft])
const handleInputChange = (field: string, value: unknown) => {
setPhaseData(prev => ({
@@ -110,23 +136,25 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
// Calculate and update average
const validMeasurements = newMeasurements.filter(m => m > 0)
if (validMeasurements.length > 0) {
- const avgDiameter = dataEntryManagement.calculateAverageDiameter(validMeasurements)
+ const avgDiameter = phaseDraftManagement.calculateAverageDiameter(validMeasurements)
handleInputChange('avg_pecan_diameter_in', avgDiameter)
}
}
const handleSave = async () => {
+ if (!selectedDraft) return
+
try {
setSaving(true)
setError(null)
// Save phase data
- const savedData = await dataEntryManagement.upsertPhaseData(dataEntry.id, phase, phaseData)
+ const savedData = await phaseDraftManagement.upsertPhaseData(selectedDraft.id, phaseData)
setPhaseData(savedData)
// Save diameter measurements if this is air-drying phase
if (phase === 'air-drying' && diameterMeasurements.some(m => m > 0)) {
- await dataEntryManagement.saveDiameterMeasurements(savedData.id, diameterMeasurements)
+ await phaseDraftManagement.saveDiameterMeasurements(savedData.id, diameterMeasurements)
}
setLastSaved(new Date())
@@ -140,6 +168,20 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
}
}
+ const handleSelectDraft = (draft: ExperimentPhaseDraft) => {
+ setSelectedDraft(draft)
+ setShowDraftManager(false)
+ loadPhaseDataForDraft(draft)
+ }
+
+ const isFieldDisabled = () => {
+ const isAdmin = currentUser.roles.includes('admin')
+ return !selectedDraft ||
+ selectedDraft.status === 'submitted' ||
+ selectedDraft.status === 'withdrawn' ||
+ (repetition.is_locked && !isAdmin)
+ }
+
const getPhaseTitle = () => {
switch (phase) {
case 'pre-soaking': return 'Pre-Soaking Phase'
@@ -181,6 +223,17 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
return (
+ {/* Draft Manager Modal */}
+ {showDraftManager && (
+ setShowDraftManager(false)}
+ />
+ )}
+
{/* Header */}
@@ -195,8 +248,32 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
Back to Phases
{getPhaseTitle()}
+ {selectedDraft && (
+
+
+ Draft: {selectedDraft.draft_name || `Draft ${selectedDraft.id.slice(-8)}`}
+
+
+ {selectedDraft.status}
+
+ {repetition.is_locked && (
+
+ 🔒 Locked
+
+ )}
+
+ )}
+
{lastSaved && (
Last saved: {lastSaved.toLocaleTimeString()}
@@ -204,7 +281,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
)}
)}
- {dataEntry.status === 'submitted' && (
+ {selectedDraft?.status === 'submitted' && (
- This entry has been submitted and is read-only. Create a new draft to make changes.
+ This draft has been submitted and is read-only. Create a new draft to make changes.
+
+
+ )}
+
+ {selectedDraft?.status === 'withdrawn' && (
+
+
+ This draft has been withdrawn. Create a new draft to make changes.
+
+
+ )}
+
+ {repetition.is_locked && !currentUser.roles.includes('admin') && (
+
+
+ This repetition has been locked by an admin. No changes can be made to drafts.
+
+
+ )}
+
+ {repetition.is_locked && currentUser.roles.includes('admin') && (
+
+
+ 🔒 This repetition is locked, but you can still make changes as an admin.
+
+
+ )}
+
+ {!selectedDraft && (
+
+
+ No draft selected. Use "Manage Drafts" to create or select a draft for this phase.
)}
@@ -246,7 +355,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
onChange={(e) => handleInputChange('batch_initial_weight_lbs', parseFloat(e.target.value) || null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
@@ -263,7 +372,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
onChange={(e) => handleInputChange('initial_shell_moisture_pct', parseFloat(e.target.value) || null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.0"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
@@ -280,7 +389,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
onChange={(e) => handleInputChange('initial_kernel_moisture_pct', parseFloat(e.target.value) || null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.0"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
@@ -293,7 +402,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
value={phaseData.soaking_start_time ? new Date(phaseData.soaking_start_time).toISOString().slice(0, 16) : ''}
onChange={(e) => handleInputChange('soaking_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
@@ -332,7 +441,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
value={phaseData.airdrying_start_time ? new Date(phaseData.airdrying_start_time).toISOString().slice(0, 16) : ''}
onChange={(e) => handleInputChange('airdrying_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
@@ -348,7 +457,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
onChange={(e) => handleInputChange('post_soak_weight_lbs', parseFloat(e.target.value) || null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
@@ -365,7 +474,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
onChange={(e) => handleInputChange('post_soak_kernel_moisture_pct', parseFloat(e.target.value) || null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.0"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
@@ -382,7 +491,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
onChange={(e) => handleInputChange('post_soak_shell_moisture_pct', parseFloat(e.target.value) || null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.0"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
@@ -422,7 +531,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
onChange={(e) => handleDiameterChange(index, parseFloat(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.000"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
))}
@@ -440,7 +549,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
onChange={(e) => handleInputChange('avg_pecan_diameter_in', parseFloat(e.target.value) || null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.000"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
Automatically calculated from individual measurements above
@@ -464,7 +573,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
value={phaseData.cracking_start_time ? new Date(phaseData.cracking_start_time).toISOString().slice(0, 16) : ''}
onChange={(e) => handleInputChange('cracking_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
@@ -536,7 +645,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
value={phaseData.shelling_start_time ? new Date(phaseData.shelling_start_time).toISOString().slice(0, 16) : ''}
onChange={(e) => handleInputChange('shelling_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)}
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
@@ -557,7 +666,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
onChange={(e) => handleInputChange('bin_1_weight_lbs', parseFloat(e.target.value) || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
@@ -572,7 +681,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
onChange={(e) => handleInputChange('bin_2_weight_lbs', parseFloat(e.target.value) || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
@@ -587,7 +696,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
onChange={(e) => handleInputChange('bin_3_weight_lbs', parseFloat(e.target.value) || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
@@ -602,7 +711,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
onChange={(e) => handleInputChange('discharge_bin_weight_lbs', parseFloat(e.target.value) || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
@@ -624,7 +733,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
onChange={(e) => handleInputChange('bin_1_full_yield_oz', parseFloat(e.target.value) || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
@@ -639,7 +748,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
onChange={(e) => handleInputChange('bin_2_full_yield_oz', parseFloat(e.target.value) || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
@@ -654,7 +763,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
onChange={(e) => handleInputChange('bin_3_full_yield_oz', parseFloat(e.target.value) || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
@@ -676,7 +785,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
onChange={(e) => handleInputChange('bin_1_half_yield_oz', parseFloat(e.target.value) || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
@@ -691,7 +800,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
onChange={(e) => handleInputChange('bin_2_half_yield_oz', parseFloat(e.target.value) || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
@@ -706,7 +815,7 @@ export function PhaseDataEntry({ experiment, dataEntry, phase, onBack, onDataSav
onChange={(e) => handleInputChange('bin_3_half_yield_oz', parseFloat(e.target.value) || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
- disabled={dataEntry.status === 'submitted'}
+ disabled={isFieldDisabled()}
/>
diff --git a/src/components/PhaseDraftManager.tsx b/src/components/PhaseDraftManager.tsx
new file mode 100644
index 0000000..abec894
--- /dev/null
+++ b/src/components/PhaseDraftManager.tsx
@@ -0,0 +1,276 @@
+import { useState, useEffect } from 'react'
+import { phaseDraftManagement, type ExperimentPhaseDraft, type ExperimentPhase, type User, type ExperimentRepetition } from '../lib/supabase'
+
+interface PhaseDraftManagerProps {
+ repetition: ExperimentRepetition
+ phase: ExperimentPhase
+ currentUser: User
+ onSelectDraft: (draft: ExperimentPhaseDraft) => void
+ onClose: () => void
+}
+
+export function PhaseDraftManager({ repetition, phase, currentUser, onSelectDraft, onClose }: PhaseDraftManagerProps) {
+ const [drafts, setDrafts] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [creating, setCreating] = useState(false)
+ const [newDraftName, setNewDraftName] = useState('')
+
+ useEffect(() => {
+ loadDrafts()
+ }, [repetition.id, phase])
+
+ const loadDrafts = async () => {
+ try {
+ setLoading(true)
+ setError(null)
+ const userDrafts = await phaseDraftManagement.getUserPhaseDraftsForPhase(repetition.id, phase)
+ setDrafts(userDrafts)
+ } catch (err: any) {
+ setError(err.message || 'Failed to load drafts')
+ console.error('Load drafts error:', err)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleCreateDraft = async () => {
+ try {
+ setCreating(true)
+ setError(null)
+
+ const newDraft = await phaseDraftManagement.createPhaseDraft({
+ experiment_id: repetition.experiment_id,
+ repetition_id: repetition.id,
+ phase_name: phase,
+ draft_name: newDraftName || undefined,
+ status: 'draft'
+ })
+
+ setDrafts(prev => [newDraft, ...prev])
+ setNewDraftName('')
+ onSelectDraft(newDraft)
+ } catch (err: any) {
+ setError(err.message || 'Failed to create draft')
+ } finally {
+ setCreating(false)
+ }
+ }
+
+ const handleDeleteDraft = async (draftId: string) => {
+ if (!confirm('Are you sure you want to delete this draft? This action cannot be undone.')) {
+ return
+ }
+
+ try {
+ await phaseDraftManagement.deletePhaseDraft(draftId)
+ setDrafts(prev => prev.filter(draft => draft.id !== draftId))
+ } catch (err: any) {
+ setError(err.message || 'Failed to delete draft')
+ }
+ }
+
+ const handleSubmitDraft = async (draftId: string) => {
+ if (!confirm('Are you sure you want to submit this draft? Once submitted, it can only be withdrawn by you or locked by an admin.')) {
+ return
+ }
+
+ try {
+ const submittedDraft = await phaseDraftManagement.submitPhaseDraft(draftId)
+ setDrafts(prev => prev.map(draft =>
+ draft.id === draftId ? submittedDraft : draft
+ ))
+ } catch (err: any) {
+ setError(err.message || 'Failed to submit draft')
+ }
+ }
+
+ const handleWithdrawDraft = async (draftId: string) => {
+ if (!confirm('Are you sure you want to withdraw this submitted draft? It will be marked as withdrawn.')) {
+ return
+ }
+
+ try {
+ const withdrawnDraft = await phaseDraftManagement.withdrawPhaseDraft(draftId)
+ setDrafts(prev => prev.map(draft =>
+ draft.id === draftId ? withdrawnDraft : draft
+ ))
+ } catch (err: any) {
+ setError(err.message || 'Failed to withdraw draft')
+ }
+ }
+
+ const getStatusBadge = (status: string) => {
+ switch (status) {
+ case 'draft':
+ return Draft
+ case 'submitted':
+ return Submitted
+ case 'withdrawn':
+ return Withdrawn
+ default:
+ return {status}
+ }
+ }
+
+ const canDeleteDraft = (draft: ExperimentPhaseDraft) => {
+ return draft.status === 'draft' && (!repetition.is_locked || currentUser.roles.includes('admin'))
+ }
+
+ const canSubmitDraft = (draft: ExperimentPhaseDraft) => {
+ return draft.status === 'draft' && (!repetition.is_locked || currentUser.roles.includes('admin'))
+ }
+
+ const canWithdrawDraft = (draft: ExperimentPhaseDraft) => {
+ return draft.status === 'submitted' && (!repetition.is_locked || currentUser.roles.includes('admin'))
+ }
+
+ const canCreateDraft = () => {
+ return !repetition.is_locked || currentUser.roles.includes('admin')
+ }
+
+ const formatPhaseTitle = (phase: string) => {
+ return phase.split('-').map(word =>
+ word.charAt(0).toUpperCase() + word.slice(1)
+ ).join(' ')
+ }
+
+ return (
+
+
+
+
+
+ {formatPhaseTitle(phase)} Phase Drafts
+
+
+ Repetition {repetition.repetition_number}
+ {repetition.is_locked && (
+
+ 🔒 Locked
+
+ )}
+
+
+
+
+
+
+ {error && (
+
+ )}
+
+ {/* Create New Draft */}
+
+ Create New Draft
+
+ setNewDraftName(e.target.value)}
+ className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ disabled={creating || repetition.is_locked}
+ />
+
+
+ {repetition.is_locked && !currentUser.roles.includes('admin') && (
+
+ Cannot create new drafts: repetition is locked by admin
+
+ )}
+
+
+ {/* Drafts List */}
+
+ {loading ? (
+
+ ) : drafts.length === 0 ? (
+
+ No drafts found for this phase
+ Create a new draft to get started
+
+ ) : (
+ drafts.map((draft) => (
+
+
+
+
+
+ {draft.draft_name || `Draft ${draft.id.slice(-8)}`}
+
+ {getStatusBadge(draft.status)}
+
+
+ Created: {new Date(draft.created_at).toLocaleString()}
+ Updated: {new Date(draft.updated_at).toLocaleString()}
+ {draft.submitted_at && (
+ Submitted: {new Date(draft.submitted_at).toLocaleString()}
+ )}
+ {draft.withdrawn_at && (
+ Withdrawn: {new Date(draft.withdrawn_at).toLocaleString()}
+ )}
+
+
+
+
+
+ {canSubmitDraft(draft) && (
+
+ )}
+
+ {canWithdrawDraft(draft) && (
+
+ )}
+
+ {canDeleteDraft(draft) && (
+
+ )}
+
+
+
+ ))
+ )}
+
+
+
+
+ )
+}
diff --git a/src/components/PhaseSelector.tsx b/src/components/PhaseSelector.tsx
index 4488f58..827df89 100644
--- a/src/components/PhaseSelector.tsx
+++ b/src/components/PhaseSelector.tsx
@@ -1,8 +1,23 @@
import { useState, useEffect } from 'react'
-import { dataEntryManagement, type ExperimentDataEntry, type ExperimentPhase, type ExperimentPhaseData } from '../lib/supabase'
+import { type ExperimentPhase, type ExperimentPhaseData } from '../lib/supabase'
+
+// DEPRECATED: This component is deprecated in favor of RepetitionPhaseSelector
+// which uses the new phase-specific draft system
+
+// Temporary type for backward compatibility
+interface LegacyDataEntry {
+ id: string
+ experiment_id: string
+ user_id: string
+ status: 'draft' | 'submitted'
+ entry_name?: string | null
+ created_at: string
+ updated_at: string
+ submitted_at?: string | null
+}
interface PhaseSelectorProps {
- dataEntry: ExperimentDataEntry
+ dataEntry: LegacyDataEntry
onPhaseSelect: (phase: ExperimentPhase) => void
}
@@ -61,7 +76,8 @@ export function PhaseSelector({ dataEntry, onPhaseSelect }: PhaseSelectorProps)
const loadPhaseData = async () => {
try {
setLoading(true)
- const allPhaseData = await dataEntryManagement.getPhaseDataForEntry(dataEntry.id)
+ // DEPRECATED: Using empty array since this component is deprecated
+ const allPhaseData: ExperimentPhaseData[] = []
const phaseDataMap: Record = {
'pre-soaking': null,
diff --git a/src/components/RepetitionDataEntryInterface.tsx b/src/components/RepetitionDataEntryInterface.tsx
new file mode 100644
index 0000000..9cf9e8e
--- /dev/null
+++ b/src/components/RepetitionDataEntryInterface.tsx
@@ -0,0 +1,115 @@
+import { useState, useEffect } from 'react'
+import { type Experiment, type ExperimentRepetition, type User, type ExperimentPhase } from '../lib/supabase'
+import { RepetitionPhaseSelector } from './RepetitionPhaseSelector'
+import { PhaseDataEntry } from './PhaseDataEntry'
+import { RepetitionLockManager } from './RepetitionLockManager'
+
+interface RepetitionDataEntryInterfaceProps {
+ experiment: Experiment
+ repetition: ExperimentRepetition
+ currentUser: User
+ onBack: () => void
+}
+
+export function RepetitionDataEntryInterface({ experiment, repetition, currentUser, onBack }: RepetitionDataEntryInterfaceProps) {
+ const [selectedPhase, setSelectedPhase] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [currentRepetition, setCurrentRepetition] = useState(repetition)
+
+ useEffect(() => {
+ // Skip loading old data entries - go directly to phase selection
+ setLoading(false)
+ }, [repetition.id, currentUser.id])
+
+
+
+ const handlePhaseSelect = (phase: ExperimentPhase) => {
+ setSelectedPhase(phase)
+ }
+
+ const handleBackToPhases = () => {
+ setSelectedPhase(null)
+ }
+
+ const handleRepetitionUpdated = (updatedRepetition: ExperimentRepetition) => {
+ setCurrentRepetition(updatedRepetition)
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
+ Experiment #{experiment.experiment_number} - Repetition #{repetition.repetition_number}
+
+
+ Soaking: {experiment.soaking_duration_hr}h • Air Drying: {experiment.air_drying_time_min}min
+ Frequency: {experiment.plate_contact_frequency_hz}Hz • Throughput: {experiment.throughput_rate_pecans_sec}/sec
+ {repetition.scheduled_date && (
+ Scheduled: {new Date(repetition.scheduled_date).toLocaleString()}
+ )}
+
+
+
+
+ {/* No additional controls needed - phase-specific draft management is handled within each phase */}
+
+
+
+ {/* Admin Controls */}
+
+
+ {/* Main Content */}
+ {selectedPhase ? (
+ {
+ // Data is automatically saved in the new phase-specific system
+ }}
+ />
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/src/components/RepetitionLockManager.tsx b/src/components/RepetitionLockManager.tsx
new file mode 100644
index 0000000..a83d2ac
--- /dev/null
+++ b/src/components/RepetitionLockManager.tsx
@@ -0,0 +1,124 @@
+import { useState } from 'react'
+import { repetitionManagement, type ExperimentRepetition, type User } from '../lib/supabase'
+
+interface RepetitionLockManagerProps {
+ repetition: ExperimentRepetition
+ currentUser: User
+ onRepetitionUpdated: (updatedRepetition: ExperimentRepetition) => void
+}
+
+export function RepetitionLockManager({ repetition, currentUser, onRepetitionUpdated }: RepetitionLockManagerProps) {
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ const isAdmin = currentUser.roles.includes('admin')
+
+ const handleLockRepetition = async () => {
+ if (!confirm('Are you sure you want to lock this repetition? This will prevent users from modifying or withdrawing any submitted drafts.')) {
+ return
+ }
+
+ try {
+ setLoading(true)
+ setError(null)
+
+ const updatedRepetition = await repetitionManagement.lockRepetition(repetition.id)
+ onRepetitionUpdated(updatedRepetition)
+ } catch (err: any) {
+ setError(err.message || 'Failed to lock repetition')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleUnlockRepetition = async () => {
+ if (!confirm('Are you sure you want to unlock this repetition? This will allow users to modify and withdraw submitted drafts again.')) {
+ return
+ }
+
+ try {
+ setLoading(true)
+ setError(null)
+
+ const updatedRepetition = await repetitionManagement.unlockRepetition(repetition.id)
+ onRepetitionUpdated(updatedRepetition)
+ } catch (err: any) {
+ setError(err.message || 'Failed to unlock repetition')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (!isAdmin) {
+ return null
+ }
+
+ return (
+
+ Admin Controls
+
+ {error && (
+
+ )}
+
+
+
+
+ Repetition Status:
+ {repetition.is_locked ? (
+
+ 🔒 Locked
+
+ ) : (
+
+ 🔓 Unlocked
+
+ )}
+
+
+ {repetition.is_locked && repetition.locked_at && (
+
+ Locked: {new Date(repetition.locked_at).toLocaleString()}
+
+ )}
+
+
+
+ {repetition.is_locked ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {repetition.is_locked ? (
+
+ When locked, users cannot create new drafts, delete existing drafts, or withdraw submitted drafts.
+ Only admins can modify the lock status.
+
+ ) : (
+
+ When unlocked, users can freely create, edit, delete, submit, and withdraw drafts.
+ Lock this repetition to prevent further changes to submitted data.
+
+ )}
+
+
+ )
+}
diff --git a/src/components/RepetitionPhaseSelector.tsx b/src/components/RepetitionPhaseSelector.tsx
new file mode 100644
index 0000000..c07922a
--- /dev/null
+++ b/src/components/RepetitionPhaseSelector.tsx
@@ -0,0 +1,223 @@
+import { useState, useEffect } from 'react'
+import { phaseDraftManagement, type ExperimentRepetition, type ExperimentPhase, type ExperimentPhaseDraft, type User } from '../lib/supabase'
+
+interface RepetitionPhaseSelectorProps {
+ repetition: ExperimentRepetition
+ currentUser: User
+ onPhaseSelect: (phase: ExperimentPhase) => void
+}
+
+interface PhaseInfo {
+ name: ExperimentPhase
+ title: string
+ description: string
+ icon: string
+ color: string
+}
+
+const phases: PhaseInfo[] = [
+ {
+ name: 'pre-soaking',
+ title: 'Pre-Soaking',
+ description: 'Initial measurements before soaking process',
+ icon: '🌰',
+ color: 'bg-blue-500'
+ },
+ {
+ name: 'air-drying',
+ title: 'Air-Drying',
+ description: 'Post-soak measurements and air-drying data',
+ icon: '💨',
+ color: 'bg-green-500'
+ },
+ {
+ name: 'cracking',
+ title: 'Cracking',
+ description: 'Cracking process timing and parameters',
+ icon: '🔨',
+ color: 'bg-yellow-500'
+ },
+ {
+ name: 'shelling',
+ title: 'Shelling',
+ description: 'Final measurements and yield data',
+ icon: '📊',
+ color: 'bg-purple-500'
+ }
+]
+
+export function RepetitionPhaseSelector({ repetition, currentUser: _currentUser, onPhaseSelect }: RepetitionPhaseSelectorProps) {
+ const [phaseDrafts, setPhaseDrafts] = useState>({
+ 'pre-soaking': [],
+ 'air-drying': [],
+ 'cracking': [],
+ 'shelling': []
+ })
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ loadPhaseDrafts()
+ }, [repetition.id])
+
+ const loadPhaseDrafts = async () => {
+ try {
+ setLoading(true)
+ setError(null)
+
+ const allDrafts = await phaseDraftManagement.getUserPhaseDraftsForRepetition(repetition.id)
+
+ // Group drafts by phase
+ const groupedDrafts: Record = {
+ 'pre-soaking': [],
+ 'air-drying': [],
+ 'cracking': [],
+ 'shelling': []
+ }
+
+ allDrafts.forEach(draft => {
+ groupedDrafts[draft.phase_name].push(draft)
+ })
+
+ setPhaseDrafts(groupedDrafts)
+ } catch (err: any) {
+ setError(err.message || 'Failed to load phase drafts')
+ console.error('Load phase drafts error:', err)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const getPhaseStatus = (phase: ExperimentPhase) => {
+ const drafts = phaseDrafts[phase]
+ if (drafts.length === 0) return 'empty'
+
+ const hasSubmitted = drafts.some(d => d.status === 'submitted')
+ const hasDraft = drafts.some(d => d.status === 'draft')
+ const hasWithdrawn = drafts.some(d => d.status === 'withdrawn')
+
+ if (hasSubmitted) return 'submitted'
+ if (hasDraft) return 'draft'
+ if (hasWithdrawn) return 'withdrawn'
+ return 'empty'
+ }
+
+ const getStatusBadge = (status: string) => {
+ switch (status) {
+ case 'submitted':
+ return Submitted
+ case 'draft':
+ return Draft
+ case 'withdrawn':
+ return Withdrawn
+ case 'empty':
+ return No Data
+ default:
+ return null
+ }
+ }
+
+ const getDraftCount = (phase: ExperimentPhase) => {
+ return phaseDrafts[phase].length
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+ Select Phase
+
+ Choose a phase to enter or view data. Each phase can have multiple drafts.
+
+ {repetition.is_locked && (
+
+
+ 🔒 This repetition is locked by an admin
+
+
+ You can view existing data but cannot create new drafts or modify existing ones.
+
+
+ )}
+
+
+
+ {phases.map((phase) => {
+ const status = getPhaseStatus(phase.name)
+ const draftCount = getDraftCount(phase.name)
+
+ return (
+ onPhaseSelect(phase.name)}
+ className="bg-white rounded-lg shadow-md border border-gray-200 p-6 cursor-pointer hover:shadow-lg hover:border-blue-300 transition-all duration-200"
+ >
+
+
+
+ {phase.icon}
+
+
+ {phase.title}
+ {phase.description}
+
+
+ {getStatusBadge(status)}
+
+
+
+
+ {draftCount === 0 ? 'No drafts' : `${draftCount} draft${draftCount === 1 ? '' : 's'}`}
+
+
+
+
+ {draftCount > 0 && (
+
+
+ {phaseDrafts[phase.name].slice(0, 3).map((draft, index) => (
+
+ {draft.draft_name || `Draft ${index + 1}`}
+
+ ))}
+ {draftCount > 3 && (
+
+ +{draftCount - 3} more
+
+ )}
+
+
+ )}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/src/components/RepetitionScheduleModal.tsx b/src/components/RepetitionScheduleModal.tsx
new file mode 100644
index 0000000..9c7848d
--- /dev/null
+++ b/src/components/RepetitionScheduleModal.tsx
@@ -0,0 +1,208 @@
+import { useState } from 'react'
+import { repetitionManagement } from '../lib/supabase'
+import type { Experiment, ExperimentRepetition } from '../lib/supabase'
+
+interface RepetitionScheduleModalProps {
+ experiment: Experiment
+ repetition: ExperimentRepetition
+ onClose: () => void
+ onScheduleUpdated: (updatedRepetition: ExperimentRepetition) => void
+}
+
+export function RepetitionScheduleModal({ experiment, repetition, onClose, onScheduleUpdated }: RepetitionScheduleModalProps) {
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ // Initialize with existing scheduled date or current date/time
+ const getInitialDateTime = () => {
+ if (repetition.scheduled_date) {
+ const date = new Date(repetition.scheduled_date)
+ return {
+ date: date.toISOString().split('T')[0],
+ time: date.toTimeString().slice(0, 5)
+ }
+ }
+
+ const now = new Date()
+ // Set to next hour by default
+ now.setHours(now.getHours() + 1, 0, 0, 0)
+ return {
+ date: now.toISOString().split('T')[0],
+ time: now.toTimeString().slice(0, 5)
+ }
+ }
+
+ const [dateTime, setDateTime] = useState(getInitialDateTime())
+ const isScheduled = repetition.scheduled_date && repetition.schedule_status === 'scheduled'
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setError(null)
+ setLoading(true)
+
+ try {
+ // Validate date/time
+ const selectedDateTime = new Date(`${dateTime.date}T${dateTime.time}`)
+ const now = new Date()
+
+ if (selectedDateTime <= now) {
+ setError('Scheduled date and time must be in the future')
+ setLoading(false)
+ return
+ }
+
+ // Schedule the repetition
+ const updatedRepetition = await repetitionManagement.scheduleRepetition(
+ repetition.id,
+ selectedDateTime.toISOString()
+ )
+
+ onScheduleUpdated(updatedRepetition)
+ onClose()
+ } catch (err: any) {
+ setError(err.message || 'Failed to schedule repetition')
+ console.error('Schedule repetition error:', err)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleRemoveSchedule = async () => {
+ if (!confirm('Are you sure you want to remove the schedule for this repetition?')) {
+ return
+ }
+
+ setError(null)
+ setLoading(true)
+
+ try {
+ const updatedRepetition = await repetitionManagement.removeRepetitionSchedule(repetition.id)
+ onScheduleUpdated(updatedRepetition)
+ onClose()
+ } catch (err: any) {
+ setError(err.message || 'Failed to remove schedule')
+ console.error('Remove schedule error:', err)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleCancel = () => {
+ onClose()
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+ Schedule Repetition
+
+
+
+
+
+ {/* Experiment and Repetition Info */}
+
+
+ Experiment #{experiment.experiment_number} - Repetition #{repetition.repetition_number}
+
+
+ {experiment.reps_required} reps required • {experiment.soaking_duration_hr}h soaking
+
+
+
+ {/* Error Message */}
+ {error && (
+
+ )}
+
+ {/* Current Schedule (if exists) */}
+ {isScheduled && (
+
+ Currently Scheduled
+
+ {new Date(repetition.scheduled_date!).toLocaleString()}
+
+
+ )}
+
+ {/* Schedule Form */}
+
+
+
+
+ )
+}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index cf002cb..c3edffb 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -67,6 +67,15 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
),
requiredRoles: ['admin', 'conductor', 'data recorder']
+ },
+ {
+ id: 'vision-system',
+ name: 'Vision System',
+ icon: (
+
+ ),
}
]
@@ -82,7 +91,7 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
{!isCollapsed && (
- RBAC System
+ Pecan Experiments
Admin Dashboard
)}
diff --git a/src/components/TopNavbar.tsx b/src/components/TopNavbar.tsx
index 1fe5e4b..fb68153 100644
--- a/src/components/TopNavbar.tsx
+++ b/src/components/TopNavbar.tsx
@@ -22,6 +22,8 @@ export function TopNavbar({ user, onLogout, currentView = 'dashboard' }: TopNavb
return 'Analytics'
case 'data-entry':
return 'Data Entry'
+ case 'vision-system':
+ return 'Vision System'
default:
return 'Dashboard'
}
diff --git a/src/components/VisionSystem.tsx b/src/components/VisionSystem.tsx
new file mode 100644
index 0000000..78aefb0
--- /dev/null
+++ b/src/components/VisionSystem.tsx
@@ -0,0 +1,735 @@
+import { useState, useEffect, useRef, useCallback } from 'react'
+import {
+ visionApi,
+ type SystemStatus,
+ type CameraStatus,
+ type MachineStatus,
+ type StorageStats,
+ type RecordingInfo,
+ type MqttStatus,
+ type MqttEventsResponse,
+ type MqttEvent,
+ formatBytes,
+ formatDuration,
+ formatUptime
+} from '../lib/visionApi'
+
+export function VisionSystem() {
+ const [systemStatus, setSystemStatus] = useState (null)
+ const [storageStats, setStorageStats] = useState(null)
+ const [recordings, setRecordings] = useState>({})
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [refreshing, setRefreshing] = useState(false)
+ const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true)
+ const [refreshInterval, setRefreshInterval] = useState(5000) // 5 seconds default
+ const [lastUpdateTime, setLastUpdateTime] = useState(null)
+ const [mqttStatus, setMqttStatus] = useState(null)
+ const [mqttEvents, setMqttEvents] = useState([])
+
+ const intervalRef = useRef(null)
+
+ const clearAutoRefresh = useCallback(() => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current)
+ intervalRef.current = null
+ }
+ }, [])
+
+ const startAutoRefresh = useCallback(() => {
+ clearAutoRefresh()
+ if (autoRefreshEnabled && refreshInterval > 0) {
+ intervalRef.current = setInterval(fetchData, refreshInterval)
+ }
+ }, [autoRefreshEnabled, refreshInterval])
+
+ useEffect(() => {
+ fetchData()
+ startAutoRefresh()
+ return clearAutoRefresh
+ }, [startAutoRefresh])
+
+ useEffect(() => {
+ startAutoRefresh()
+ }, [autoRefreshEnabled, refreshInterval, startAutoRefresh])
+
+ const fetchData = useCallback(async (showRefreshIndicator = true) => {
+ try {
+ setError(null)
+ if (!systemStatus) {
+ setLoading(true)
+ } else if (showRefreshIndicator) {
+ setRefreshing(true)
+ }
+
+ const [statusData, storageData, recordingsData, mqttStatusData, mqttEventsData] = await Promise.all([
+ visionApi.getSystemStatus(),
+ visionApi.getStorageStats(),
+ visionApi.getRecordings(),
+ visionApi.getMqttStatus().catch(err => {
+ console.warn('Failed to fetch MQTT status:', err)
+ return null
+ }),
+ visionApi.getMqttEvents(10).catch(err => {
+ console.warn('Failed to fetch MQTT events:', err)
+ return { events: [], total_events: 0, last_updated: '' }
+ })
+ ])
+
+ // If cameras don't have device_info, try to fetch individual camera status
+ if (statusData.cameras) {
+ const camerasNeedingInfo = Object.entries(statusData.cameras)
+ .filter(([_, camera]) => !camera.device_info?.friendly_name)
+ .map(([cameraName, _]) => cameraName)
+
+ if (camerasNeedingInfo.length > 0) {
+ console.log('Fetching individual camera info for:', camerasNeedingInfo)
+ try {
+ const individualCameraData = await Promise.all(
+ camerasNeedingInfo.map(cameraName =>
+ visionApi.getCameraStatus(cameraName).catch(err => {
+ console.warn(`Failed to get individual status for ${cameraName}:`, err)
+ return null
+ })
+ )
+ )
+
+ // Merge the individual camera data back into statusData
+ camerasNeedingInfo.forEach((cameraName, index) => {
+ const individualData = individualCameraData[index]
+ if (individualData && individualData.device_info) {
+ statusData.cameras[cameraName] = {
+ ...statusData.cameras[cameraName],
+ device_info: individualData.device_info
+ }
+ }
+ })
+ } catch (err) {
+ console.warn('Failed to fetch individual camera data:', err)
+ }
+ }
+ }
+
+ // Only update state if data has actually changed to prevent unnecessary re-renders
+ setSystemStatus(prevStatus => {
+ if (JSON.stringify(prevStatus) !== JSON.stringify(statusData)) {
+ return statusData
+ }
+ return prevStatus
+ })
+
+ setStorageStats(prevStats => {
+ if (JSON.stringify(prevStats) !== JSON.stringify(storageData)) {
+ return storageData
+ }
+ return prevStats
+ })
+
+ setRecordings(prevRecordings => {
+ if (JSON.stringify(prevRecordings) !== JSON.stringify(recordingsData)) {
+ return recordingsData
+ }
+ return prevRecordings
+ })
+
+ setLastUpdateTime(new Date())
+
+ // Update MQTT status and events
+ if (mqttStatusData) {
+ setMqttStatus(mqttStatusData)
+ }
+
+ if (mqttEventsData && mqttEventsData.events) {
+ setMqttEvents(mqttEventsData.events)
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to fetch vision system data')
+ console.error('Vision system fetch error:', err)
+ // Don't disable auto-refresh on errors, just log them
+ } finally {
+ setLoading(false)
+ setRefreshing(false)
+ }
+ }, [systemStatus])
+
+ const getStatusColor = (status: string, isRecording: boolean = false) => {
+ // If camera is recording, always show red regardless of status
+ if (isRecording) {
+ return 'text-red-600 bg-red-100'
+ }
+
+ switch (status.toLowerCase()) {
+ case 'available':
+ case 'connected':
+ case 'healthy':
+ case 'on':
+ return 'text-green-600 bg-green-100'
+ case 'disconnected':
+ case 'off':
+ case 'failed':
+ return 'text-red-600 bg-red-100'
+ case 'error':
+ case 'warning':
+ case 'degraded':
+ return 'text-yellow-600 bg-yellow-100'
+ default:
+ return 'text-yellow-600 bg-yellow-100'
+ }
+ }
+
+ const getMachineStateColor = (state: string) => {
+ switch (state.toLowerCase()) {
+ case 'on':
+ case 'running':
+ return 'text-green-600 bg-green-100'
+ case 'off':
+ case 'stopped':
+ return 'text-gray-600 bg-gray-100'
+ default:
+ return 'text-yellow-600 bg-yellow-100'
+ }
+ }
+
+ if (loading) {
+ return (
+
+
+
+
+ Loading vision system data...
+
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
+
+
+ Error loading vision system
+
+
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ Vision System
+ Monitor cameras, machines, and recording status
+ {lastUpdateTime && (
+
+ Last updated: {lastUpdateTime.toLocaleTimeString()}
+ {autoRefreshEnabled && !refreshing && (
+
+ Auto-refresh: {refreshInterval / 1000}s
+
+ )}
+
+ )}
+
+
+ {/* Auto-refresh controls */}
+
+
+ {autoRefreshEnabled && (
+
+ )}
+
+
+ {/* Refresh indicator and button */}
+
+ {refreshing && (
+
+ )}
+
+
+
+
+
+ {/* System Overview */}
+ {systemStatus && (
+
+
+
+
+
+
+ {systemStatus.system_started ? 'Online' : 'Offline'}
+
+
+
+
+ System Status
+
+ Uptime: {formatUptime(systemStatus.uptime_seconds)}
+
+
+
+
+
+
+
+
+
+
+
+ {systemStatus.mqtt_connected ? 'Connected' : 'Disconnected'}
+
+
+ {systemStatus.mqtt_connected && (
+
+ )}
+
+ {mqttStatus && (
+
+ {mqttStatus.message_count} messages
+ {mqttStatus.error_count} errors
+
+ )}
+
+
+ MQTT
+
+ {mqttStatus ? (
+
+ Broker: {mqttStatus.broker_host}:{mqttStatus.broker_port}
+ Last message: {new Date(mqttStatus.last_message_time).toLocaleTimeString()}
+
+ ) : (
+ Last message: {new Date(systemStatus.last_mqtt_message).toLocaleTimeString()}
+ )}
+
+
+
+ {/* MQTT Events History */}
+ {mqttEvents.length > 0 && (
+
+
+ Recent Events
+ {mqttEvents.length} events
+
+
+ {mqttEvents.map((event, index) => (
+
+
+
+ {new Date(event.timestamp).toLocaleTimeString().slice(-8, -3)}
+
+
+ {event.machine_name.replace('_', ' ')}
+
+
+ {event.payload}
+
+
+ #{event.message_number}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+
+ {systemStatus.active_recordings}
+
+
+
+
+ Active Recordings
+
+ Total: {systemStatus.total_recordings}
+
+
+
+
+
+
+
+
+
+
+ {Object.keys(systemStatus.cameras).length}
+
+
+
+
+ Cameras
+
+ Machines: {Object.keys(systemStatus.machines).length}
+
+
+
+
+
+ )}
+
+
+
+ {/* Cameras Status */}
+ {systemStatus && (
+
+
+ Cameras
+
+ Current status of all cameras in the system
+
+
+
+
+ {Object.entries(systemStatus.cameras).map(([cameraName, camera]) => {
+ // Debug logging to see what data we're getting
+ console.log(`Camera ${cameraName} data:`, JSON.stringify(camera, null, 2))
+
+ const friendlyName = camera.device_info?.friendly_name
+ const hasDeviceInfo = !!camera.device_info
+ const hasSerial = !!camera.device_info?.serial_number
+
+ return (
+
+
+
+
+ {friendlyName ? (
+
+ {friendlyName}
+ ({cameraName})
+
+ ) : (
+
+ {cameraName}
+
+ {hasDeviceInfo ? 'Device info available but no friendly name' : 'No device info available'}
+
+
+ )}
+
+
+
+ {camera.is_recording ? 'Recording' : camera.status}
+
+
+
+
+
+ Recording:
+
+ {camera.is_recording ? 'Yes' : 'No'}
+
+
+
+ {camera.device_info?.serial_number && (
+
+ Serial:
+ {camera.device_info.serial_number}
+
+ )}
+
+ {/* Debug info - remove this after fixing */}
+
+ Debug Info:
+
+ Has device_info: {hasDeviceInfo ? 'Yes' : 'No'}
+ Has friendly_name: {friendlyName ? 'Yes' : 'No'}
+ Has serial: {hasSerial ? 'Yes' : 'No'}
+ Last error: {camera.last_error || 'None'}
+ {camera.device_info && (
+
+ Raw device_info: {JSON.stringify(camera.device_info)}
+
+ )}
+
+
+
+
+ Last checked:
+ {new Date(camera.last_checked).toLocaleTimeString()}
+
+
+ {camera.current_recording_file && (
+
+ Recording file:
+ {camera.current_recording_file}
+
+ )}
+
+
+ )
+ })}
+
+
+
+ )}
+
+ {/* Machines Status */}
+ {systemStatus && Object.keys(systemStatus.machines).length > 0 && (
+
+
+ Machines
+
+ Current status of all machines in the system
+
+
+
+
+ {Object.entries(systemStatus.machines).map(([machineName, machine]) => (
+
+
+
+ {machineName.replace(/_/g, ' ')}
+
+
+ {machine.state}
+
+
+
+
+
+ Last updated:
+ {new Date(machine.last_updated).toLocaleTimeString()}
+
+
+ {machine.last_message && (
+
+ Last message:
+ {machine.last_message}
+
+ )}
+
+ {machine.mqtt_topic && (
+
+ MQTT topic:
+ {machine.mqtt_topic}
+
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+ {/* Storage Statistics */}
+ {storageStats && (
+
+
+ Storage
+
+ Storage usage and file statistics
+
+
+
+
+
+ {storageStats.total_files}
+ Total Files
+
+
+ {formatBytes(storageStats.total_size_bytes)}
+ Total Size
+
+
+ {formatBytes(storageStats.disk_usage.free)}
+ Free Space
+
+
+
+ {/* Disk Usage Bar */}
+
+
+ Disk Usage
+ {Math.round((storageStats.disk_usage.used / storageStats.disk_usage.total) * 100)}% used
+
+
+
+ {formatBytes(storageStats.disk_usage.used)} used
+ {formatBytes(storageStats.disk_usage.total)} total
+
+
+
+ {/* Per-Camera Statistics */}
+ {Object.keys(storageStats.cameras).length > 0 && (
+
+ Files by Camera
+
+ {Object.entries(storageStats.cameras).map(([cameraName, stats]) => {
+ // Find the corresponding camera to get friendly name
+ const camera = systemStatus?.cameras[cameraName]
+ const displayName = camera?.device_info?.friendly_name || cameraName
+
+ return (
+
+
+ {camera?.device_info?.friendly_name ? (
+ <>
+ {displayName}
+ ({cameraName})
+ >
+ ) : (
+ cameraName
+ )}
+
+
+
+ Files:
+ {stats.file_count}
+
+
+ Size:
+ {formatBytes(stats.total_size_bytes)}
+
+
+
+ )
+ })}
+
+
+ )}
+
+
+ )}
+
+ {/* Recent Recordings */}
+ {Object.keys(recordings).length > 0 && (
+
+
+ Recent Recordings
+
+ Latest recording sessions
+
+
+
+
+
+
+
+ |
+ Camera
+ |
+
+ Filename
+ |
+
+ Status
+ |
+
+ Duration
+ |
+
+ Size
+ |
+
+ Started
+ |
+
+
+
+ {Object.entries(recordings).slice(0, 10).map(([recordingId, recording]) => {
+ // Find the corresponding camera to get friendly name
+ const camera = systemStatus?.cameras[recording.camera_name]
+ const displayName = camera?.device_info?.friendly_name || recording.camera_name
+
+ return (
+
+
+ {camera?.device_info?.friendly_name ? (
+
+ {displayName}
+ ({recording.camera_name})
+
+ ) : (
+ recording.camera_name
+ )}
+ |
+
+ {recording.filename}
+ |
+
+
+ {recording.state}
+
+ |
+
+ {recording.duration_seconds ? formatDuration(recording.duration_seconds) : '-'}
+ |
+
+ {recording.file_size_bytes ? formatBytes(recording.file_size_bytes) : '-'}
+ |
+
+ {new Date(recording.start_time).toLocaleString()}
+ |
+
+ )
+ })}
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts
index 7d943d1..b772f1c 100644
--- a/src/lib/supabase.ts
+++ b/src/lib/supabase.ts
@@ -38,15 +38,15 @@ export interface Experiment {
throughput_rate_pecans_sec: number
crush_amount_in: number
entry_exit_height_diff_in: number
- schedule_status: ScheduleStatus
results_status: ResultsStatus
completion_status: boolean
- scheduled_date?: string | null
created_at: string
updated_at: string
created_by: string
}
+
+
export interface CreateExperimentRequest {
experiment_number: number
reps_required: number
@@ -56,10 +56,8 @@ export interface CreateExperimentRequest {
throughput_rate_pecans_sec: number
crush_amount_in: number
entry_exit_height_diff_in: number
- schedule_status?: ScheduleStatus
results_status?: ResultsStatus
completion_status?: boolean
- scheduled_date?: string | null
}
export interface UpdateExperimentRequest {
@@ -71,25 +69,54 @@ export interface UpdateExperimentRequest {
throughput_rate_pecans_sec?: number
crush_amount_in?: number
entry_exit_height_diff_in?: number
- schedule_status?: ScheduleStatus
results_status?: ResultsStatus
completion_status?: boolean
+}
+
+export interface CreateRepetitionRequest {
+ experiment_id: string
+ repetition_number: number
scheduled_date?: string | null
+ schedule_status?: ScheduleStatus
+}
+
+export interface UpdateRepetitionRequest {
+ scheduled_date?: string | null
+ schedule_status?: ScheduleStatus
+ completion_status?: boolean
}
// Data Entry System Interfaces
-export type DataEntryStatus = 'draft' | 'submitted'
+export type PhaseDraftStatus = 'draft' | 'submitted' | 'withdrawn'
export type ExperimentPhase = 'pre-soaking' | 'air-drying' | 'cracking' | 'shelling'
-export interface ExperimentDataEntry {
+export interface ExperimentPhaseDraft {
id: string
experiment_id: string
+ repetition_id: string
user_id: string
- status: DataEntryStatus
- entry_name?: string | null
+ phase_name: ExperimentPhase
+ status: PhaseDraftStatus
+ draft_name?: string | null
created_at: string
updated_at: string
submitted_at?: string | null
+ withdrawn_at?: string | null
+}
+
+export interface ExperimentRepetition {
+ id: string
+ experiment_id: string
+ repetition_number: number
+ scheduled_date?: string | null
+ schedule_status: ScheduleStatus
+ completion_status: boolean
+ is_locked: boolean
+ locked_at?: string | null
+ locked_by?: string | null
+ created_at: string
+ updated_at: string
+ created_by: string
}
export interface PecanDiameterMeasurement {
@@ -102,7 +129,7 @@ export interface PecanDiameterMeasurement {
export interface ExperimentPhaseData {
id: string
- data_entry_id: string
+ phase_draft_id: string
phase_name: ExperimentPhase
// Pre-soaking phase
@@ -141,15 +168,17 @@ export interface ExperimentPhaseData {
diameter_measurements?: PecanDiameterMeasurement[]
}
-export interface CreateDataEntryRequest {
+export interface CreatePhaseDraftRequest {
experiment_id: string
- entry_name?: string
- status?: DataEntryStatus
+ repetition_id: string
+ phase_name: ExperimentPhase
+ draft_name?: string
+ status?: PhaseDraftStatus
}
-export interface UpdateDataEntryRequest {
- entry_name?: string
- status?: DataEntryStatus
+export interface UpdatePhaseDraftRequest {
+ draft_name?: string
+ status?: PhaseDraftStatus
}
export interface CreatePhaseDataRequest {
@@ -440,25 +469,7 @@ export const experimentManagement = {
return data
},
- // Schedule an experiment
- async scheduleExperiment(id: string, scheduledDate: string): Promise {
- const updates: UpdateExperimentRequest = {
- scheduled_date: scheduledDate,
- schedule_status: 'scheduled'
- }
- return this.updateExperiment(id, updates)
- },
-
- // Remove experiment schedule
- async removeExperimentSchedule(id: string): Promise {
- const updates: UpdateExperimentRequest = {
- scheduled_date: null,
- schedule_status: 'pending schedule'
- }
-
- return this.updateExperiment(id, updates)
- },
// Check if experiment number is unique
async isExperimentNumberUnique(experimentNumber: number, excludeId?: string): Promise {
@@ -478,45 +489,237 @@ export const experimentManagement = {
}
}
-// Data Entry Management
-export const dataEntryManagement = {
- // Get all data entries for an experiment
- async getDataEntriesForExperiment(experimentId: string): Promise {
+// Experiment Repetitions Management
+export const repetitionManagement = {
+ // Get all repetitions for an experiment
+ async getExperimentRepetitions(experimentId: string): Promise {
const { data, error } = await supabase
- .from('experiment_data_entries')
+ .from('experiment_repetitions')
.select('*')
.eq('experiment_id', experimentId)
+ .order('repetition_number', { ascending: true })
+
+ if (error) throw error
+ return data
+ },
+
+ // Create a new repetition
+ async createRepetition(repetitionData: CreateRepetitionRequest): Promise {
+ const { data: { user }, error: authError } = await supabase.auth.getUser()
+ if (authError || !user) throw new Error('User not authenticated')
+
+ const { data, error } = await supabase
+ .from('experiment_repetitions')
+ .insert({
+ ...repetitionData,
+ created_by: user.id
+ })
+ .select()
+ .single()
+
+ if (error) throw error
+ return data
+ },
+
+ // Update a repetition
+ async updateRepetition(id: string, updates: UpdateRepetitionRequest): Promise {
+ const { data, error } = await supabase
+ .from('experiment_repetitions')
+ .update(updates)
+ .eq('id', id)
+ .select()
+ .single()
+
+ if (error) throw error
+ return data
+ },
+
+ // Schedule a repetition
+ async scheduleRepetition(id: string, scheduledDate: string): Promise {
+ const updates: UpdateRepetitionRequest = {
+ scheduled_date: scheduledDate,
+ schedule_status: 'scheduled'
+ }
+
+ return this.updateRepetition(id, updates)
+ },
+
+ // Remove repetition schedule
+ async removeRepetitionSchedule(id: string): Promise {
+ const updates: UpdateRepetitionRequest = {
+ scheduled_date: null,
+ schedule_status: 'pending schedule'
+ }
+
+ return this.updateRepetition(id, updates)
+ },
+
+ // Delete a repetition
+ async deleteRepetition(id: string): Promise {
+ const { error } = await supabase
+ .from('experiment_repetitions')
+ .delete()
+ .eq('id', id)
+
+ if (error) throw error
+ },
+
+ // Get repetitions by status
+ async getRepetitionsByStatus(scheduleStatus?: ScheduleStatus): Promise {
+ let query = supabase.from('experiment_repetitions').select('*')
+
+ if (scheduleStatus) {
+ query = query.eq('schedule_status', scheduleStatus)
+ }
+
+ const { data, error } = await query.order('created_at', { ascending: false })
+
+ if (error) throw error
+ return data
+ },
+
+ // Get repetitions with experiment details
+ async getRepetitionsWithExperiments(): Promise<(ExperimentRepetition & { experiment: Experiment })[]> {
+ const { data, error } = await supabase
+ .from('experiment_repetitions')
+ .select(`
+ *,
+ experiment:experiments(*)
+ `)
.order('created_at', { ascending: false })
if (error) throw error
return data
},
- // Get user's data entries for an experiment
- async getUserDataEntriesForExperiment(experimentId: string, userId?: string): Promise {
+ // Create all repetitions for an experiment
+ async createAllRepetitions(experimentId: string): Promise {
+ // First get the experiment to know how many reps are required
+ const { data: experiment, error: expError } = await supabase
+ .from('experiments')
+ .select('reps_required')
+ .eq('id', experimentId)
+ .single()
+
+ if (expError) throw expError
+
+ // Create repetitions for each required rep
+ const repetitions: CreateRepetitionRequest[] = []
+ for (let i = 1; i <= experiment.reps_required; i++) {
+ repetitions.push({
+ experiment_id: experimentId,
+ repetition_number: i,
+ schedule_status: 'pending schedule'
+ })
+ }
+
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
- const targetUserId = userId || user.id
+ const { data, error } = await supabase
+ .from('experiment_repetitions')
+ .insert(repetitions.map(rep => ({
+ ...rep,
+ created_by: user.id
+ })))
+ .select()
+
+ if (error) throw error
+ return data
+ },
+
+ // Lock a repetition (admin only)
+ async lockRepetition(repetitionId: string): Promise {
+ const { data: { user }, error: authError } = await supabase.auth.getUser()
+ if (authError || !user) throw new Error('User not authenticated')
const { data, error } = await supabase
- .from('experiment_data_entries')
+ .from('experiment_repetitions')
+ .update({
+ is_locked: true,
+ locked_at: new Date().toISOString(),
+ locked_by: user.id
+ })
+ .eq('id', repetitionId)
+ .select()
+ .single()
+
+ if (error) throw error
+ return data
+ },
+
+ // Unlock a repetition (admin only)
+ async unlockRepetition(repetitionId: string): Promise {
+ const { data, error } = await supabase
+ .from('experiment_repetitions')
+ .update({
+ is_locked: false,
+ locked_at: null,
+ locked_by: null
+ })
+ .eq('id', repetitionId)
+ .select()
+ .single()
+
+ if (error) throw error
+ return data
+ }
+}
+
+// Phase Draft Management
+export const phaseDraftManagement = {
+ // Get all phase drafts for a repetition
+ async getPhaseDraftsForRepetition(repetitionId: string): Promise {
+ const { data, error } = await supabase
+ .from('experiment_phase_drafts')
.select('*')
- .eq('experiment_id', experimentId)
- .eq('user_id', targetUserId)
+ .eq('repetition_id', repetitionId)
.order('created_at', { ascending: false })
if (error) throw error
return data
},
- // Create a new data entry
- async createDataEntry(request: CreateDataEntryRequest): Promise {
+ // Get user's phase drafts for a repetition
+ async getUserPhaseDraftsForRepetition(repetitionId: string): Promise {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
const { data, error } = await supabase
- .from('experiment_data_entries')
+ .from('experiment_phase_drafts')
+ .select('*')
+ .eq('repetition_id', repetitionId)
+ .eq('user_id', user.id)
+ .order('created_at', { ascending: false })
+
+ if (error) throw error
+ return data
+ },
+
+ // Get user's phase drafts for a specific phase and repetition
+ async getUserPhaseDraftsForPhase(repetitionId: string, phase: ExperimentPhase): Promise {
+ const { data: { user }, error: authError } = await supabase.auth.getUser()
+ if (authError || !user) throw new Error('User not authenticated')
+
+ const { data, error } = await supabase
+ .from('experiment_phase_drafts')
+ .select('*')
+ .eq('repetition_id', repetitionId)
+ .eq('user_id', user.id)
+ .eq('phase_name', phase)
+ .order('created_at', { ascending: false })
+
+ if (error) throw error
+ return data
+ },
+
+ // Create a new phase draft
+ async createPhaseDraft(request: CreatePhaseDraftRequest): Promise {
+ const { data: { user }, error: authError } = await supabase.auth.getUser()
+ if (authError || !user) throw new Error('User not authenticated')
+
+ const { data, error } = await supabase
+ .from('experiment_phase_drafts')
.insert({
...request,
user_id: user.id
@@ -528,10 +731,10 @@ export const dataEntryManagement = {
return data
},
- // Update a data entry
- async updateDataEntry(id: string, updates: UpdateDataEntryRequest): Promise {
+ // Update a phase draft
+ async updatePhaseDraft(id: string, updates: UpdatePhaseDraftRequest): Promise {
const { data, error } = await supabase
- .from('experiment_data_entries')
+ .from('experiment_phase_drafts')
.update(updates)
.eq('id', id)
.select()
@@ -541,65 +744,53 @@ export const dataEntryManagement = {
return data
},
- // Delete a data entry (only drafts)
- async deleteDataEntry(id: string): Promise {
+ // Delete a phase draft (only drafts)
+ async deletePhaseDraft(id: string): Promise {
const { error } = await supabase
- .from('experiment_data_entries')
+ .from('experiment_phase_drafts')
.delete()
.eq('id', id)
if (error) throw error
},
- // Submit a data entry (change status from draft to submitted)
- async submitDataEntry(id: string): Promise {
- return this.updateDataEntry(id, { status: 'submitted' })
+ // Submit a phase draft (change status from draft to submitted)
+ async submitPhaseDraft(id: string): Promise {
+ return this.updatePhaseDraft(id, { status: 'submitted' })
},
- // Get phase data for a data entry
- async getPhaseDataForEntry(dataEntryId: string): Promise {
+ // Withdraw a phase draft (change status from submitted to withdrawn)
+ async withdrawPhaseDraft(id: string): Promise {
+ return this.updatePhaseDraft(id, { status: 'withdrawn' })
+ },
+
+ // Get phase data for a phase draft
+ async getPhaseDataForDraft(phaseDraftId: string): Promise {
const { data, error } = await supabase
.from('experiment_phase_data')
.select(`
*,
diameter_measurements:pecan_diameter_measurements(*)
`)
- .eq('data_entry_id', dataEntryId)
- .order('phase_name')
-
- if (error) throw error
- return data
- },
-
- // Get specific phase data
- async getPhaseData(dataEntryId: string, phaseName: ExperimentPhase): Promise {
- const { data, error } = await supabase
- .from('experiment_phase_data')
- .select(`
- *,
- diameter_measurements:pecan_diameter_measurements(*)
- `)
- .eq('data_entry_id', dataEntryId)
- .eq('phase_name', phaseName)
+ .eq('phase_draft_id', phaseDraftId)
.single()
if (error) {
- if (error.code === 'PGRST116') return null // Not found
+ if (error.code === 'PGRST116') return null // No rows found
throw error
}
return data
},
- // Create or update phase data
- async upsertPhaseData(dataEntryId: string, phaseName: ExperimentPhase, phaseData: Partial): Promise {
+ // Create or update phase data for a draft
+ async upsertPhaseData(phaseDraftId: string, phaseData: Partial): Promise {
const { data, error } = await supabase
.from('experiment_phase_data')
.upsert({
- data_entry_id: dataEntryId,
- phase_name: phaseName,
+ phase_draft_id: phaseDraftId,
...phaseData
}, {
- onConflict: 'data_entry_id,phase_name'
+ onConflict: 'phase_draft_id,phase_name'
})
.select()
.single()
@@ -641,9 +832,9 @@ export const dataEntryManagement = {
},
// Auto-save draft data (for periodic saves)
- async autoSaveDraft(dataEntryId: string, phaseName: ExperimentPhase, phaseData: Partial): Promise {
+ async autoSaveDraft(phaseDraftId: string, phaseData: Partial): Promise {
try {
- await this.upsertPhaseData(dataEntryId, phaseName, phaseData)
+ await this.upsertPhaseData(phaseDraftId, phaseData)
} catch (error) {
console.warn('Auto-save failed:', error)
// Don't throw error for auto-save failures
diff --git a/src/lib/visionApi.ts b/src/lib/visionApi.ts
new file mode 100644
index 0000000..8a08a07
--- /dev/null
+++ b/src/lib/visionApi.ts
@@ -0,0 +1,336 @@
+// Vision System API Client
+// Base URL for the vision system API
+const VISION_API_BASE_URL = 'http://localhost:8000'
+
+// Types based on the API documentation
+export interface SystemStatus {
+ system_started: boolean
+ mqtt_connected: boolean
+ last_mqtt_message: string
+ machines: Record
+ cameras: Record
+ active_recordings: number
+ total_recordings: number
+ uptime_seconds: number
+}
+
+export interface MachineStatus {
+ name: string
+ state: string
+ last_updated: string
+ last_message?: string
+ mqtt_topic?: string
+}
+
+export interface CameraStatus {
+ name: string
+ status: string
+ is_recording: boolean
+ last_checked: string
+ last_error: string | null
+ device_info?: {
+ friendly_name: string
+ serial_number: string
+ }
+ current_recording_file: string | null
+ recording_start_time: string | null
+}
+
+export interface RecordingInfo {
+ camera_name: string
+ filename: string
+ start_time: string
+ state: string
+ end_time?: string
+ file_size_bytes?: number
+ frame_count?: number
+ duration_seconds?: number
+ error_message?: string | null
+}
+
+export interface StorageStats {
+ base_path: string
+ total_files: number
+ total_size_bytes: number
+ cameras: Record
+ disk_usage: {
+ total: number
+ used: number
+ free: number
+ }
+}
+
+export interface RecordingFile {
+ filename: string
+ camera_name: string
+ file_size_bytes: number
+ created_date: string
+ duration_seconds?: number
+}
+
+export interface StartRecordingRequest {
+ filename?: string
+ exposure_ms?: number
+ gain?: number
+ fps?: number
+}
+
+export interface StartRecordingResponse {
+ success: boolean
+ message: string
+ filename: string
+}
+
+export interface StopRecordingResponse {
+ success: boolean
+ message: string
+ duration_seconds: number
+}
+
+export interface CameraTestResponse {
+ success: boolean
+ message: string
+ camera_name: string
+ timestamp: string
+}
+
+export interface CameraRecoveryResponse {
+ success: boolean
+ message: string
+ camera_name: string
+ operation: string
+ timestamp: string
+}
+
+export interface MqttMessage {
+ timestamp: string
+ topic: string
+ message: string
+ source: string
+}
+
+export interface MqttStatus {
+ connected: boolean
+ broker_host: string
+ broker_port: number
+ subscribed_topics: string[]
+ last_message_time: string
+ message_count: number
+ error_count: number
+ uptime_seconds: number
+}
+
+export interface MqttEvent {
+ machine_name: string
+ topic: string
+ payload: string
+ normalized_state: string
+ timestamp: string
+ message_number: number
+}
+
+export interface MqttEventsResponse {
+ events: MqttEvent[]
+ total_events: number
+ last_updated: string
+}
+
+export interface FileListRequest {
+ camera_name?: string
+ start_date?: string
+ end_date?: string
+ limit?: number
+}
+
+export interface FileListResponse {
+ files: RecordingFile[]
+ total_count: number
+}
+
+export interface CleanupRequest {
+ max_age_days?: number
+}
+
+export interface CleanupResponse {
+ files_removed: number
+ bytes_freed: number
+ errors: string[]
+}
+
+// API Client Class
+class VisionApiClient {
+ private baseUrl: string
+
+ constructor(baseUrl: string = VISION_API_BASE_URL) {
+ this.baseUrl = baseUrl
+ }
+
+ private async request(endpoint: string, options: RequestInit = {}): Promise {
+ const url = `${this.baseUrl}${endpoint}`
+
+ const response = await fetch(url, {
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ },
+ ...options,
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`)
+ }
+
+ return response.json()
+ }
+
+ // System endpoints
+ async getHealth(): Promise<{ status: string; timestamp: string }> {
+ return this.request('/health')
+ }
+
+ async getSystemStatus(): Promise {
+ return this.request('/system/status')
+ }
+
+ // Machine endpoints
+ async getMachines(): Promise> {
+ return this.request('/machines')
+ }
+
+ // MQTT endpoints
+ async getMqttStatus(): Promise {
+ return this.request('/mqtt/status')
+ }
+
+ async getMqttEvents(limit: number = 10): Promise {
+ return this.request(`/mqtt/events?limit=${limit}`)
+ }
+
+ // Camera endpoints
+ async getCameras(): Promise> {
+ return this.request('/cameras')
+ }
+
+ async getCameraStatus(cameraName: string): Promise {
+ return this.request(`/cameras/${cameraName}/status`)
+ }
+
+ // Recording control
+ async startRecording(cameraName: string, params: StartRecordingRequest = {}): Promise {
+ return this.request(`/cameras/${cameraName}/start-recording`, {
+ method: 'POST',
+ body: JSON.stringify(params),
+ })
+ }
+
+ async stopRecording(cameraName: string): Promise {
+ return this.request(`/cameras/${cameraName}/stop-recording`, {
+ method: 'POST',
+ })
+ }
+
+ // Camera diagnostics
+ async testCameraConnection(cameraName: string): Promise {
+ return this.request(`/cameras/${cameraName}/test-connection`, {
+ method: 'POST',
+ })
+ }
+
+ async reconnectCamera(cameraName: string): Promise {
+ return this.request(`/cameras/${cameraName}/reconnect`, {
+ method: 'POST',
+ })
+ }
+
+ async restartCameraGrab(cameraName: string): Promise {
+ return this.request(`/cameras/${cameraName}/restart-grab`, {
+ method: 'POST',
+ })
+ }
+
+ async resetCameraTimestamp(cameraName: string): Promise {
+ return this.request(`/cameras/${cameraName}/reset-timestamp`, {
+ method: 'POST',
+ })
+ }
+
+ async fullCameraReset(cameraName: string): Promise {
+ return this.request(`/cameras/${cameraName}/full-reset`, {
+ method: 'POST',
+ })
+ }
+
+ async reinitializeCamera(cameraName: string): Promise {
+ return this.request(`/cameras/${cameraName}/reinitialize`, {
+ method: 'POST',
+ })
+ }
+
+ // Recording sessions
+ async getRecordings(): Promise> {
+ return this.request('/recordings')
+ }
+
+ // Storage endpoints
+ async getStorageStats(): Promise {
+ return this.request('/storage/stats')
+ }
+
+ async getFiles(params: FileListRequest = {}): Promise {
+ return this.request('/storage/files', {
+ method: 'POST',
+ body: JSON.stringify(params),
+ })
+ }
+
+ async cleanupStorage(params: CleanupRequest = {}): Promise {
+ return this.request('/storage/cleanup', {
+ method: 'POST',
+ body: JSON.stringify(params),
+ })
+ }
+}
+
+// Export a singleton instance
+export const visionApi = new VisionApiClient()
+
+// Utility functions
+export const formatBytes = (bytes: number): string => {
+ if (bytes === 0) return '0 Bytes'
+ const k = 1024
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+}
+
+export const formatDuration = (seconds: number): string => {
+ const hours = Math.floor(seconds / 3600)
+ const minutes = Math.floor((seconds % 3600) / 60)
+ const secs = Math.floor(seconds % 60)
+
+ if (hours > 0) {
+ return `${hours}h ${minutes}m ${secs}s`
+ } else if (minutes > 0) {
+ return `${minutes}m ${secs}s`
+ } else {
+ return `${secs}s`
+ }
+}
+
+export const formatUptime = (seconds: number): string => {
+ const days = Math.floor(seconds / 86400)
+ const hours = Math.floor((seconds % 86400) / 3600)
+ const minutes = Math.floor((seconds % 3600) / 60)
+
+ if (days > 0) {
+ return `${days}d ${hours}h ${minutes}m`
+ } else if (hours > 0) {
+ return `${hours}h ${minutes}m`
+ } else {
+ return `${minutes}m`
+ }
+}
diff --git a/src/test/visionApi.test.ts b/src/test/visionApi.test.ts
new file mode 100644
index 0000000..dce1fa8
--- /dev/null
+++ b/src/test/visionApi.test.ts
@@ -0,0 +1,51 @@
+// Simple test file to verify vision API client functionality
+// This is not a formal test suite, just a manual verification script
+
+import { visionApi, formatBytes, formatDuration, formatUptime } from '../lib/visionApi'
+
+// Test utility functions
+console.log('Testing utility functions:')
+console.log('formatBytes(1024):', formatBytes(1024)) // Should be "1 KB"
+console.log('formatBytes(1048576):', formatBytes(1048576)) // Should be "1 MB"
+console.log('formatDuration(65):', formatDuration(65)) // Should be "1m 5s"
+console.log('formatUptime(3661):', formatUptime(3661)) // Should be "1h 1m"
+
+// Test API endpoints (these will fail if vision system is not running)
+export async function testVisionApi() {
+ try {
+ console.log('Testing vision API endpoints...')
+
+ // Test health endpoint
+ const health = await visionApi.getHealth()
+ console.log('Health check:', health)
+
+ // Test system status
+ const status = await visionApi.getSystemStatus()
+ console.log('System status:', status)
+
+ // Test cameras
+ const cameras = await visionApi.getCameras()
+ console.log('Cameras:', cameras)
+
+ // Test machines
+ const machines = await visionApi.getMachines()
+ console.log('Machines:', machines)
+
+ // Test storage stats
+ const storage = await visionApi.getStorageStats()
+ console.log('Storage stats:', storage)
+
+ // Test recordings
+ const recordings = await visionApi.getRecordings()
+ console.log('Recordings:', recordings)
+
+ console.log('All API tests passed!')
+ return true
+ } catch (error) {
+ console.error('API test failed:', error)
+ return false
+ }
+}
+
+// Uncomment the line below to run the test when this file is imported
+// testVisionApi()
diff --git a/supabase/migrations/20250723000001_experiment_data_entry_system.sql b/supabase/migrations/20250723000001_experiment_data_entry_system.sql
deleted file mode 100644
index 8a9866c..0000000
--- a/supabase/migrations/20250723000001_experiment_data_entry_system.sql
+++ /dev/null
@@ -1,263 +0,0 @@
--- Experiment Data Entry System Migration
--- Creates tables for collaborative data entry with draft functionality and phase-based organization
-
--- Create experiment_data_entries table for main data entry records
-CREATE TABLE IF NOT EXISTS public.experiment_data_entries (
- id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
- experiment_id UUID NOT NULL REFERENCES public.experiments(id) ON DELETE CASCADE,
- user_id UUID NOT NULL REFERENCES public.user_profiles(id),
- status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'submitted')),
- entry_name TEXT, -- Optional name for the entry (e.g., "Morning Run", "Batch A")
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
- updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
- submitted_at TIMESTAMP WITH TIME ZONE, -- When status changed to 'submitted'
-
- -- Constraint: Only one submitted entry per user per experiment
- CONSTRAINT unique_submitted_entry_per_user_experiment
- EXCLUDE (experiment_id WITH =, user_id WITH =)
- WHERE (status = 'submitted')
-);
-
--- Create experiment_phase_data table for phase-specific measurements
-CREATE TABLE IF NOT EXISTS public.experiment_phase_data (
- id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
- data_entry_id UUID NOT NULL REFERENCES public.experiment_data_entries(id) ON DELETE CASCADE,
- phase_name TEXT NOT NULL CHECK (phase_name IN ('pre-soaking', 'air-drying', 'cracking', 'shelling')),
-
- -- Pre-soaking phase data
- batch_initial_weight_lbs FLOAT CHECK (batch_initial_weight_lbs >= 0),
- initial_shell_moisture_pct FLOAT CHECK (initial_shell_moisture_pct >= 0 AND initial_shell_moisture_pct <= 100),
- initial_kernel_moisture_pct FLOAT CHECK (initial_kernel_moisture_pct >= 0 AND initial_kernel_moisture_pct <= 100),
- soaking_start_time TIMESTAMP WITH TIME ZONE,
-
- -- Air-drying phase data
- airdrying_start_time TIMESTAMP WITH TIME ZONE,
- post_soak_weight_lbs FLOAT CHECK (post_soak_weight_lbs >= 0),
- post_soak_kernel_moisture_pct FLOAT CHECK (post_soak_kernel_moisture_pct >= 0 AND post_soak_kernel_moisture_pct <= 100),
- post_soak_shell_moisture_pct FLOAT CHECK (post_soak_shell_moisture_pct >= 0 AND post_soak_shell_moisture_pct <= 100),
- avg_pecan_diameter_in FLOAT CHECK (avg_pecan_diameter_in >= 0),
-
- -- Cracking phase data
- cracking_start_time TIMESTAMP WITH TIME ZONE,
-
- -- Shelling phase data
- shelling_start_time TIMESTAMP WITH TIME ZONE,
- bin_1_weight_lbs FLOAT CHECK (bin_1_weight_lbs >= 0),
- bin_2_weight_lbs FLOAT CHECK (bin_2_weight_lbs >= 0),
- bin_3_weight_lbs FLOAT CHECK (bin_3_weight_lbs >= 0),
- discharge_bin_weight_lbs FLOAT CHECK (discharge_bin_weight_lbs >= 0),
- bin_1_full_yield_oz FLOAT CHECK (bin_1_full_yield_oz >= 0),
- bin_2_full_yield_oz FLOAT CHECK (bin_2_full_yield_oz >= 0),
- bin_3_full_yield_oz FLOAT CHECK (bin_3_full_yield_oz >= 0),
- bin_1_half_yield_oz FLOAT CHECK (bin_1_half_yield_oz >= 0),
- bin_2_half_yield_oz FLOAT CHECK (bin_2_half_yield_oz >= 0),
- bin_3_half_yield_oz FLOAT CHECK (bin_3_half_yield_oz >= 0),
-
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
- updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-
- -- Constraint: One record per phase per data entry
- CONSTRAINT unique_phase_per_data_entry UNIQUE (data_entry_id, phase_name)
-);
-
--- Create pecan_diameter_measurements table for individual diameter measurements
-CREATE TABLE IF NOT EXISTS public.pecan_diameter_measurements (
- id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
- phase_data_id UUID NOT NULL REFERENCES public.experiment_phase_data(id) ON DELETE CASCADE,
- measurement_number INTEGER NOT NULL CHECK (measurement_number >= 1 AND measurement_number <= 10),
- diameter_in FLOAT NOT NULL CHECK (diameter_in >= 0),
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-
- -- Constraint: Unique measurement number per phase data
- CONSTRAINT unique_measurement_per_phase UNIQUE (phase_data_id, measurement_number)
-);
-
--- Create indexes for better performance
-CREATE INDEX IF NOT EXISTS idx_experiment_data_entries_experiment_id ON public.experiment_data_entries(experiment_id);
-CREATE INDEX IF NOT EXISTS idx_experiment_data_entries_user_id ON public.experiment_data_entries(user_id);
-CREATE INDEX IF NOT EXISTS idx_experiment_data_entries_status ON public.experiment_data_entries(status);
-CREATE INDEX IF NOT EXISTS idx_experiment_data_entries_created_at ON public.experiment_data_entries(created_at);
-
-CREATE INDEX IF NOT EXISTS idx_experiment_phase_data_entry_id ON public.experiment_phase_data(data_entry_id);
-CREATE INDEX IF NOT EXISTS idx_experiment_phase_data_phase_name ON public.experiment_phase_data(phase_name);
-
-CREATE INDEX IF NOT EXISTS idx_pecan_diameter_measurements_phase_data_id ON public.pecan_diameter_measurements(phase_data_id);
-
--- Create triggers for updated_at
-CREATE TRIGGER set_updated_at_experiment_data_entries
- BEFORE UPDATE ON public.experiment_data_entries
- FOR EACH ROW
- EXECUTE FUNCTION public.handle_updated_at();
-
-CREATE TRIGGER set_updated_at_experiment_phase_data
- BEFORE UPDATE ON public.experiment_phase_data
- FOR EACH ROW
- EXECUTE FUNCTION public.handle_updated_at();
-
--- Create trigger to set submitted_at timestamp
-CREATE OR REPLACE FUNCTION public.handle_data_entry_submission()
-RETURNS TRIGGER AS $$
-BEGIN
- -- Set submitted_at when status changes to 'submitted'
- IF NEW.status = 'submitted' AND OLD.status != 'submitted' THEN
- NEW.submitted_at = NOW();
- END IF;
-
- -- Clear submitted_at when status changes from 'submitted' to 'draft'
- IF NEW.status = 'draft' AND OLD.status = 'submitted' THEN
- NEW.submitted_at = NULL;
- END IF;
-
- RETURN NEW;
-END;
-$$ LANGUAGE plpgsql;
-
-CREATE TRIGGER set_submitted_at_experiment_data_entries
- BEFORE UPDATE ON public.experiment_data_entries
- FOR EACH ROW
- EXECUTE FUNCTION public.handle_data_entry_submission();
-
--- Enable RLS on all tables
-ALTER TABLE public.experiment_data_entries ENABLE ROW LEVEL SECURITY;
-ALTER TABLE public.experiment_phase_data ENABLE ROW LEVEL SECURITY;
-ALTER TABLE public.pecan_diameter_measurements ENABLE ROW LEVEL SECURITY;
-
--- RLS Policies for experiment_data_entries table
-
--- Policy: All authenticated users can view all data entries
-CREATE POLICY "experiment_data_entries_select_policy" ON public.experiment_data_entries
- FOR SELECT
- TO authenticated
- USING (true);
-
--- Policy: All authenticated users can insert data entries
-CREATE POLICY "experiment_data_entries_insert_policy" ON public.experiment_data_entries
- FOR INSERT
- TO authenticated
- WITH CHECK (user_id = auth.uid());
-
--- Policy: Users can only update their own data entries
-CREATE POLICY "experiment_data_entries_update_policy" ON public.experiment_data_entries
- FOR UPDATE
- TO authenticated
- USING (user_id = auth.uid())
- WITH CHECK (user_id = auth.uid());
-
--- Policy: Users can only delete their own draft entries
-CREATE POLICY "experiment_data_entries_delete_policy" ON public.experiment_data_entries
- FOR DELETE
- TO authenticated
- USING (user_id = auth.uid() AND status = 'draft');
-
--- RLS Policies for experiment_phase_data table
-
--- Policy: All authenticated users can view phase data
-CREATE POLICY "experiment_phase_data_select_policy" ON public.experiment_phase_data
- FOR SELECT
- TO authenticated
- USING (true);
-
--- Policy: Users can insert phase data for their own data entries
-CREATE POLICY "experiment_phase_data_insert_policy" ON public.experiment_phase_data
- FOR INSERT
- TO authenticated
- WITH CHECK (
- EXISTS (
- SELECT 1 FROM public.experiment_data_entries ede
- WHERE ede.id = data_entry_id AND ede.user_id = auth.uid()
- )
- );
-
--- Policy: Users can update phase data for their own data entries
-CREATE POLICY "experiment_phase_data_update_policy" ON public.experiment_phase_data
- FOR UPDATE
- TO authenticated
- USING (
- EXISTS (
- SELECT 1 FROM public.experiment_data_entries ede
- WHERE ede.id = data_entry_id AND ede.user_id = auth.uid()
- )
- )
- WITH CHECK (
- EXISTS (
- SELECT 1 FROM public.experiment_data_entries ede
- WHERE ede.id = data_entry_id AND ede.user_id = auth.uid()
- )
- );
-
--- Policy: Users can delete phase data for their own draft entries
-CREATE POLICY "experiment_phase_data_delete_policy" ON public.experiment_phase_data
- FOR DELETE
- TO authenticated
- USING (
- EXISTS (
- SELECT 1 FROM public.experiment_data_entries ede
- WHERE ede.id = data_entry_id AND ede.user_id = auth.uid() AND ede.status = 'draft'
- )
- );
-
--- RLS Policies for pecan_diameter_measurements table
-
--- Policy: All authenticated users can view diameter measurements
-CREATE POLICY "pecan_diameter_measurements_select_policy" ON public.pecan_diameter_measurements
- FOR SELECT
- TO authenticated
- USING (true);
-
--- Policy: Users can insert measurements for their own phase data
-CREATE POLICY "pecan_diameter_measurements_insert_policy" ON public.pecan_diameter_measurements
- FOR INSERT
- TO authenticated
- WITH CHECK (
- EXISTS (
- SELECT 1 FROM public.experiment_phase_data epd
- JOIN public.experiment_data_entries ede ON epd.data_entry_id = ede.id
- WHERE epd.id = phase_data_id AND ede.user_id = auth.uid()
- )
- );
-
--- Policy: Users can update measurements for their own phase data
-CREATE POLICY "pecan_diameter_measurements_update_policy" ON public.pecan_diameter_measurements
- FOR UPDATE
- TO authenticated
- USING (
- EXISTS (
- SELECT 1 FROM public.experiment_phase_data epd
- JOIN public.experiment_data_entries ede ON epd.data_entry_id = ede.id
- WHERE epd.id = phase_data_id AND ede.user_id = auth.uid()
- )
- )
- WITH CHECK (
- EXISTS (
- SELECT 1 FROM public.experiment_phase_data epd
- JOIN public.experiment_data_entries ede ON epd.data_entry_id = ede.id
- WHERE epd.id = phase_data_id AND ede.user_id = auth.uid()
- )
- );
-
--- Policy: Users can delete measurements for their own draft entries
-CREATE POLICY "pecan_diameter_measurements_delete_policy" ON public.pecan_diameter_measurements
- FOR DELETE
- TO authenticated
- USING (
- EXISTS (
- SELECT 1 FROM public.experiment_phase_data epd
- JOIN public.experiment_data_entries ede ON epd.data_entry_id = ede.id
- WHERE epd.id = phase_data_id AND ede.user_id = auth.uid() AND ede.status = 'draft'
- )
- );
-
--- Add comments for documentation
-COMMENT ON TABLE public.experiment_data_entries IS 'Main data entry records for experiments with draft/submitted status tracking';
-COMMENT ON TABLE public.experiment_phase_data IS 'Phase-specific measurement data for experiments';
-COMMENT ON TABLE public.pecan_diameter_measurements IS 'Individual pecan diameter measurements (up to 10 per phase)';
-
-COMMENT ON COLUMN public.experiment_data_entries.status IS 'Entry status: draft (editable) or submitted (final)';
-COMMENT ON COLUMN public.experiment_data_entries.entry_name IS 'Optional descriptive name for the data entry';
-COMMENT ON COLUMN public.experiment_data_entries.submitted_at IS 'Timestamp when entry was submitted (status changed to submitted)';
-
-COMMENT ON COLUMN public.experiment_phase_data.phase_name IS 'Experiment phase: pre-soaking, air-drying, cracking, or shelling';
-COMMENT ON COLUMN public.experiment_phase_data.avg_pecan_diameter_in IS 'Average of up to 10 individual diameter measurements';
-
-COMMENT ON COLUMN public.pecan_diameter_measurements.measurement_number IS 'Measurement sequence number (1-10)';
-COMMENT ON COLUMN public.pecan_diameter_measurements.diameter_in IS 'Individual pecan diameter measurement in inches';
diff --git a/supabase/migrations/20250724000001_experiment_repetitions_system.sql b/supabase/migrations/20250724000001_experiment_repetitions_system.sql
new file mode 100644
index 0000000..dc45274
--- /dev/null
+++ b/supabase/migrations/20250724000001_experiment_repetitions_system.sql
@@ -0,0 +1,135 @@
+-- Experiment Repetitions System Migration
+-- Transforms experiments into blueprints/templates with schedulable repetitions
+-- This migration creates the repetitions table and removes scheduling from experiments
+
+-- Note: Data clearing removed since this runs during fresh database setup
+
+-- Create experiment_repetitions table
+CREATE TABLE IF NOT EXISTS public.experiment_repetitions (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ experiment_id UUID NOT NULL REFERENCES public.experiments(id) ON DELETE CASCADE,
+ repetition_number INTEGER NOT NULL CHECK (repetition_number > 0),
+ scheduled_date TIMESTAMP WITH TIME ZONE,
+ schedule_status TEXT NOT NULL DEFAULT 'pending schedule'
+ CHECK (schedule_status IN ('pending schedule', 'scheduled', 'canceled', 'aborted')),
+ completion_status BOOLEAN NOT NULL DEFAULT false,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ created_by UUID NOT NULL REFERENCES public.user_profiles(id),
+
+ -- Ensure unique repetition numbers per experiment
+ CONSTRAINT unique_repetition_per_experiment UNIQUE (experiment_id, repetition_number)
+);
+
+-- Create indexes for better performance
+CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_experiment_id ON public.experiment_repetitions(experiment_id);
+CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_schedule_status ON public.experiment_repetitions(schedule_status);
+CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_completion_status ON public.experiment_repetitions(completion_status);
+CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_scheduled_date ON public.experiment_repetitions(scheduled_date);
+CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_created_by ON public.experiment_repetitions(created_by);
+CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_created_at ON public.experiment_repetitions(created_at);
+
+-- Remove scheduling fields from experiments table since experiments are now blueprints
+ALTER TABLE public.experiments DROP COLUMN IF EXISTS scheduled_date;
+ALTER TABLE public.experiments DROP COLUMN IF EXISTS schedule_status;
+
+-- Drop related indexes that are no longer needed
+DROP INDEX IF EXISTS idx_experiments_schedule_status;
+DROP INDEX IF EXISTS idx_experiments_scheduled_date;
+
+-- Note: experiment_data_entries table is replaced by experiment_phase_drafts in the new system
+
+-- Function to validate repetition number doesn't exceed experiment's reps_required
+CREATE OR REPLACE FUNCTION validate_repetition_number()
+RETURNS TRIGGER AS $$
+DECLARE
+ max_reps INTEGER;
+BEGIN
+ -- Get the reps_required for this experiment
+ SELECT reps_required INTO max_reps
+ FROM public.experiments
+ WHERE id = NEW.experiment_id;
+
+ -- Check if repetition number exceeds the limit
+ IF NEW.repetition_number > max_reps THEN
+ RAISE EXCEPTION 'Repetition number % exceeds maximum allowed repetitions % for experiment',
+ NEW.repetition_number, max_reps;
+ END IF;
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Update the trigger function for experiment_repetitions
+CREATE OR REPLACE FUNCTION update_experiment_repetitions_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Create trigger to validate repetition number
+CREATE TRIGGER trigger_validate_repetition_number
+ BEFORE INSERT OR UPDATE ON public.experiment_repetitions
+ FOR EACH ROW
+ EXECUTE FUNCTION validate_repetition_number();
+
+-- Create trigger for updated_at on experiment_repetitions
+CREATE TRIGGER trigger_experiment_repetitions_updated_at
+ BEFORE UPDATE ON public.experiment_repetitions
+ FOR EACH ROW
+ EXECUTE FUNCTION update_experiment_repetitions_updated_at();
+
+-- Enable RLS on experiment_repetitions table
+ALTER TABLE public.experiment_repetitions ENABLE ROW LEVEL SECURITY;
+
+-- Create RLS policies for experiment_repetitions
+-- Users can view repetitions for experiments they have access to
+CREATE POLICY "Users can view experiment repetitions" ON public.experiment_repetitions
+ FOR SELECT USING (
+ experiment_id IN (
+ SELECT id FROM public.experiments
+ WHERE created_by = auth.uid()
+ )
+ OR public.is_admin()
+ );
+
+-- Users can insert repetitions for experiments they created or if they're admin
+CREATE POLICY "Users can create experiment repetitions" ON public.experiment_repetitions
+ FOR INSERT WITH CHECK (
+ experiment_id IN (
+ SELECT id FROM public.experiments
+ WHERE created_by = auth.uid()
+ )
+ OR public.is_admin()
+ );
+
+-- Users can update repetitions for experiments they created or if they're admin
+CREATE POLICY "Users can update experiment repetitions" ON public.experiment_repetitions
+ FOR UPDATE USING (
+ experiment_id IN (
+ SELECT id FROM public.experiments
+ WHERE created_by = auth.uid()
+ )
+ OR public.is_admin()
+ );
+
+-- Users can delete repetitions for experiments they created or if they're admin
+CREATE POLICY "Users can delete experiment repetitions" ON public.experiment_repetitions
+ FOR DELETE USING (
+ experiment_id IN (
+ SELECT id FROM public.experiments
+ WHERE created_by = auth.uid()
+ )
+ OR public.is_admin()
+ );
+
+-- Add comments for documentation
+COMMENT ON TABLE public.experiment_repetitions IS 'Individual repetitions of experiment blueprints that can be scheduled and executed';
+COMMENT ON COLUMN public.experiment_repetitions.experiment_id IS 'Reference to the experiment blueprint';
+COMMENT ON COLUMN public.experiment_repetitions.repetition_number IS 'Sequential number of this repetition (1, 2, 3, etc.)';
+COMMENT ON COLUMN public.experiment_repetitions.scheduled_date IS 'Date and time when this repetition is scheduled to run';
+COMMENT ON COLUMN public.experiment_repetitions.schedule_status IS 'Current scheduling status of this repetition';
+COMMENT ON COLUMN public.experiment_repetitions.completion_status IS 'Whether this repetition has been completed';
+-- Note: experiment_data_entries table is replaced by experiment_phase_drafts in the new system
diff --git a/supabase/migrations/20250725000001_experiment_data_entry_system.sql b/supabase/migrations/20250725000001_experiment_data_entry_system.sql
new file mode 100644
index 0000000..2114ad3
--- /dev/null
+++ b/supabase/migrations/20250725000001_experiment_data_entry_system.sql
@@ -0,0 +1,332 @@
+-- Phase-Specific Draft System Migration
+-- Creates tables for the new phase-specific draft management system
+
+-- Create experiment_phase_drafts table for phase-specific draft management
+CREATE TABLE IF NOT EXISTS public.experiment_phase_drafts (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ experiment_id UUID NOT NULL REFERENCES public.experiments(id) ON DELETE CASCADE,
+ repetition_id UUID NOT NULL REFERENCES public.experiment_repetitions(id) ON DELETE CASCADE,
+ user_id UUID NOT NULL REFERENCES public.user_profiles(id),
+ phase_name TEXT NOT NULL CHECK (phase_name IN ('pre-soaking', 'air-drying', 'cracking', 'shelling')),
+ status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'withdrawn')),
+ draft_name TEXT, -- Optional name for the draft (e.g., "Morning Run", "Batch A")
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ submitted_at TIMESTAMP WITH TIME ZONE, -- When status changed to 'submitted'
+ withdrawn_at TIMESTAMP WITH TIME ZONE -- When status changed to 'withdrawn'
+);
+
+-- Add repetition locking support
+ALTER TABLE public.experiment_repetitions
+ADD COLUMN IF NOT EXISTS is_locked BOOLEAN NOT NULL DEFAULT false,
+ADD COLUMN IF NOT EXISTS locked_at TIMESTAMP WITH TIME ZONE,
+ADD COLUMN IF NOT EXISTS locked_by UUID REFERENCES public.user_profiles(id);
+
+-- Create experiment_phase_data table for phase-specific measurements
+CREATE TABLE IF NOT EXISTS public.experiment_phase_data (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ phase_draft_id UUID NOT NULL REFERENCES public.experiment_phase_drafts(id) ON DELETE CASCADE,
+ phase_name TEXT NOT NULL CHECK (phase_name IN ('pre-soaking', 'air-drying', 'cracking', 'shelling')),
+
+ -- Pre-soaking phase data
+ batch_initial_weight_lbs FLOAT CHECK (batch_initial_weight_lbs >= 0),
+ initial_shell_moisture_pct FLOAT CHECK (initial_shell_moisture_pct >= 0 AND initial_shell_moisture_pct <= 100),
+ initial_kernel_moisture_pct FLOAT CHECK (initial_kernel_moisture_pct >= 0 AND initial_kernel_moisture_pct <= 100),
+ soaking_start_time TIMESTAMP WITH TIME ZONE,
+
+ -- Air-drying phase data
+ airdrying_start_time TIMESTAMP WITH TIME ZONE,
+ post_soak_weight_lbs FLOAT CHECK (post_soak_weight_lbs >= 0),
+ post_soak_kernel_moisture_pct FLOAT CHECK (post_soak_kernel_moisture_pct >= 0 AND post_soak_kernel_moisture_pct <= 100),
+ post_soak_shell_moisture_pct FLOAT CHECK (post_soak_shell_moisture_pct >= 0 AND post_soak_shell_moisture_pct <= 100),
+ avg_pecan_diameter_in FLOAT CHECK (avg_pecan_diameter_in >= 0),
+
+ -- Cracking phase data
+ cracking_start_time TIMESTAMP WITH TIME ZONE,
+
+ -- Shelling phase data
+ shelling_start_time TIMESTAMP WITH TIME ZONE,
+ bin_1_weight_lbs FLOAT CHECK (bin_1_weight_lbs >= 0),
+ bin_2_weight_lbs FLOAT CHECK (bin_2_weight_lbs >= 0),
+ bin_3_weight_lbs FLOAT CHECK (bin_3_weight_lbs >= 0),
+ discharge_bin_weight_lbs FLOAT CHECK (discharge_bin_weight_lbs >= 0),
+ bin_1_full_yield_oz FLOAT CHECK (bin_1_full_yield_oz >= 0),
+ bin_2_full_yield_oz FLOAT CHECK (bin_2_full_yield_oz >= 0),
+ bin_3_full_yield_oz FLOAT CHECK (bin_3_full_yield_oz >= 0),
+ bin_1_half_yield_oz FLOAT CHECK (bin_1_half_yield_oz >= 0),
+ bin_2_half_yield_oz FLOAT CHECK (bin_2_half_yield_oz >= 0),
+ bin_3_half_yield_oz FLOAT CHECK (bin_3_half_yield_oz >= 0),
+
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ -- Constraint: One record per phase draft
+ CONSTRAINT unique_phase_per_draft UNIQUE (phase_draft_id, phase_name)
+);
+
+-- Create pecan_diameter_measurements table for individual diameter measurements
+CREATE TABLE IF NOT EXISTS public.pecan_diameter_measurements (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ phase_data_id UUID NOT NULL REFERENCES public.experiment_phase_data(id) ON DELETE CASCADE,
+ measurement_number INTEGER NOT NULL CHECK (measurement_number >= 1 AND measurement_number <= 10),
+ diameter_in FLOAT NOT NULL CHECK (diameter_in >= 0),
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ -- Constraint: Unique measurement number per phase data
+ CONSTRAINT unique_measurement_per_phase UNIQUE (phase_data_id, measurement_number)
+);
+
+-- Create indexes for better performance
+CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_experiment_id ON public.experiment_phase_drafts(experiment_id);
+CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_repetition_id ON public.experiment_phase_drafts(repetition_id);
+CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_user_id ON public.experiment_phase_drafts(user_id);
+CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_phase_name ON public.experiment_phase_drafts(phase_name);
+CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_status ON public.experiment_phase_drafts(status);
+CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_is_locked ON public.experiment_repetitions(is_locked);
+
+CREATE INDEX IF NOT EXISTS idx_experiment_phase_data_draft_id ON public.experiment_phase_data(phase_draft_id);
+CREATE INDEX IF NOT EXISTS idx_experiment_phase_data_phase_name ON public.experiment_phase_data(phase_name);
+
+CREATE INDEX IF NOT EXISTS idx_pecan_diameter_measurements_phase_data_id ON public.pecan_diameter_measurements(phase_data_id);
+
+-- Create triggers for updated_at
+CREATE TRIGGER set_updated_at_experiment_phase_drafts
+ BEFORE UPDATE ON public.experiment_phase_drafts
+ FOR EACH ROW
+ EXECUTE FUNCTION public.handle_updated_at();
+
+CREATE TRIGGER set_updated_at_experiment_phase_data
+ BEFORE UPDATE ON public.experiment_phase_data
+ FOR EACH ROW
+ EXECUTE FUNCTION public.handle_updated_at();
+
+-- Create trigger to set submitted_at and withdrawn_at timestamps for phase drafts
+CREATE OR REPLACE FUNCTION public.handle_phase_draft_status_change()
+RETURNS TRIGGER AS $$
+BEGIN
+ -- Set submitted_at when status changes to 'submitted'
+ IF NEW.status = 'submitted' AND OLD.status != 'submitted' THEN
+ NEW.submitted_at = NOW();
+ NEW.withdrawn_at = NULL;
+ END IF;
+
+ -- Set withdrawn_at when status changes to 'withdrawn'
+ IF NEW.status = 'withdrawn' AND OLD.status = 'submitted' THEN
+ NEW.withdrawn_at = NOW();
+ END IF;
+
+ -- Clear timestamps when status changes back to 'draft'
+ IF NEW.status = 'draft' AND OLD.status IN ('submitted', 'withdrawn') THEN
+ NEW.submitted_at = NULL;
+ NEW.withdrawn_at = NULL;
+ END IF;
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER set_timestamps_experiment_phase_drafts
+ BEFORE UPDATE ON public.experiment_phase_drafts
+ FOR EACH ROW
+ EXECUTE FUNCTION public.handle_phase_draft_status_change();
+
+-- Enable RLS on all tables
+ALTER TABLE public.experiment_phase_drafts ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.experiment_phase_data ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.pecan_diameter_measurements ENABLE ROW LEVEL SECURITY;
+
+-- RLS Policies for experiment_phase_drafts table
+
+-- Policy: All authenticated users can view all phase drafts
+CREATE POLICY "experiment_phase_drafts_select_policy" ON public.experiment_phase_drafts
+ FOR SELECT
+ TO authenticated
+ USING (true);
+
+-- Policy: All authenticated users can insert phase drafts
+CREATE POLICY "experiment_phase_drafts_insert_policy" ON public.experiment_phase_drafts
+ FOR INSERT
+ TO authenticated
+ WITH CHECK (user_id = auth.uid());
+
+-- Policy: Users can update their own phase drafts if repetition is not locked, admins can update any
+CREATE POLICY "experiment_phase_drafts_update_policy" ON public.experiment_phase_drafts
+ FOR UPDATE
+ TO authenticated
+ USING (
+ (user_id = auth.uid() AND NOT EXISTS (
+ SELECT 1 FROM public.experiment_repetitions
+ WHERE id = repetition_id AND is_locked = true
+ )) OR public.is_admin()
+ )
+ WITH CHECK (
+ (user_id = auth.uid() AND NOT EXISTS (
+ SELECT 1 FROM public.experiment_repetitions
+ WHERE id = repetition_id AND is_locked = true
+ )) OR public.is_admin()
+ );
+
+-- Policy: Users can delete their own draft phase drafts if repetition is not locked, admins can delete any
+CREATE POLICY "experiment_phase_drafts_delete_policy" ON public.experiment_phase_drafts
+ FOR DELETE
+ TO authenticated
+ USING (
+ (user_id = auth.uid() AND status = 'draft' AND NOT EXISTS (
+ SELECT 1 FROM public.experiment_repetitions
+ WHERE id = repetition_id AND is_locked = true
+ )) OR public.is_admin()
+ );
+
+-- RLS Policies for experiment_phase_data table
+
+-- Policy: All authenticated users can view phase data
+CREATE POLICY "experiment_phase_data_select_policy" ON public.experiment_phase_data
+ FOR SELECT
+ TO authenticated
+ USING (true);
+
+-- Policy: Users can insert phase data for their own phase drafts
+CREATE POLICY "experiment_phase_data_insert_policy" ON public.experiment_phase_data
+ FOR INSERT
+ TO authenticated
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.experiment_phase_drafts epd
+ WHERE epd.id = phase_draft_id AND epd.user_id = auth.uid()
+ )
+ );
+
+-- Policy: Users can update phase data for their own phase drafts
+CREATE POLICY "experiment_phase_data_update_policy" ON public.experiment_phase_data
+ FOR UPDATE
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.experiment_phase_drafts epd
+ WHERE epd.id = phase_draft_id AND epd.user_id = auth.uid()
+ )
+ )
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.experiment_phase_drafts epd
+ WHERE epd.id = phase_draft_id AND epd.user_id = auth.uid()
+ )
+ );
+
+-- Policy: Users can delete phase data for their own draft phase drafts
+CREATE POLICY "experiment_phase_data_delete_policy" ON public.experiment_phase_data
+ FOR DELETE
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.experiment_phase_drafts epd
+ WHERE epd.id = phase_draft_id AND epd.user_id = auth.uid() AND epd.status = 'draft'
+ )
+ );
+
+-- RLS Policies for pecan_diameter_measurements table
+
+-- Policy: All authenticated users can view diameter measurements
+CREATE POLICY "pecan_diameter_measurements_select_policy" ON public.pecan_diameter_measurements
+ FOR SELECT
+ TO authenticated
+ USING (true);
+
+-- Policy: Users can insert measurements for their own phase data
+CREATE POLICY "pecan_diameter_measurements_insert_policy" ON public.pecan_diameter_measurements
+ FOR INSERT
+ TO authenticated
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.experiment_phase_data epd
+ JOIN public.experiment_phase_drafts epdr ON epd.phase_draft_id = epdr.id
+ WHERE epd.id = phase_data_id AND epdr.user_id = auth.uid()
+ )
+ );
+
+-- Policy: Users can update measurements for their own phase data
+CREATE POLICY "pecan_diameter_measurements_update_policy" ON public.pecan_diameter_measurements
+ FOR UPDATE
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.experiment_phase_data epd
+ JOIN public.experiment_phase_drafts epdr ON epd.phase_draft_id = epdr.id
+ WHERE epd.id = phase_data_id AND epdr.user_id = auth.uid()
+ )
+ )
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.experiment_phase_data epd
+ JOIN public.experiment_phase_drafts epdr ON epd.phase_draft_id = epdr.id
+ WHERE epd.id = phase_data_id AND epdr.user_id = auth.uid()
+ )
+ );
+
+-- Policy: Users can delete measurements for their own draft phase drafts
+CREATE POLICY "pecan_diameter_measurements_delete_policy" ON public.pecan_diameter_measurements
+ FOR DELETE
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.experiment_phase_data epd
+ JOIN public.experiment_phase_drafts epdr ON epd.phase_draft_id = epdr.id
+ WHERE epd.id = phase_data_id AND epdr.user_id = auth.uid() AND epdr.status = 'draft'
+ )
+ );
+
+-- Add indexes for better performance
+CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_repetition_id ON public.experiment_phase_drafts(repetition_id);
+CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_user_id ON public.experiment_phase_drafts(user_id);
+CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_phase_name ON public.experiment_phase_drafts(phase_name);
+CREATE INDEX IF NOT EXISTS idx_experiment_phase_drafts_status ON public.experiment_phase_drafts(status);
+CREATE INDEX IF NOT EXISTS idx_experiment_repetitions_is_locked ON public.experiment_repetitions(is_locked);
+
+-- Add comments for documentation
+COMMENT ON TABLE public.experiment_phase_drafts IS 'Phase-specific draft records for experiment data entry with status tracking';
+COMMENT ON TABLE public.experiment_phase_data IS 'Phase-specific measurement data for experiments';
+COMMENT ON TABLE public.pecan_diameter_measurements IS 'Individual pecan diameter measurements (up to 10 per phase)';
+
+COMMENT ON COLUMN public.experiment_phase_drafts.status IS 'Draft status: draft (editable), submitted (final), or withdrawn (reverted from submitted)';
+COMMENT ON COLUMN public.experiment_phase_drafts.draft_name IS 'Optional descriptive name for the draft';
+COMMENT ON COLUMN public.experiment_phase_drafts.submitted_at IS 'Timestamp when draft was submitted (status changed to submitted)';
+COMMENT ON COLUMN public.experiment_phase_drafts.withdrawn_at IS 'Timestamp when draft was withdrawn (status changed from submitted to withdrawn)';
+
+COMMENT ON COLUMN public.experiment_repetitions.is_locked IS 'Admin lock to prevent draft modifications and withdrawals';
+COMMENT ON COLUMN public.experiment_repetitions.locked_at IS 'Timestamp when repetition was locked';
+COMMENT ON COLUMN public.experiment_repetitions.locked_by IS 'User who locked the repetition';
+
+COMMENT ON COLUMN public.experiment_phase_data.phase_name IS 'Experiment phase: pre-soaking, air-drying, cracking, or shelling';
+COMMENT ON COLUMN public.experiment_phase_data.avg_pecan_diameter_in IS 'Average of up to 10 individual diameter measurements';
+
+COMMENT ON COLUMN public.pecan_diameter_measurements.measurement_number IS 'Measurement sequence number (1-10)';
+COMMENT ON COLUMN public.pecan_diameter_measurements.diameter_in IS 'Individual pecan diameter measurement in inches';
+
+-- Add unique constraint to prevent multiple drafts of same phase by same user for same repetition
+ALTER TABLE public.experiment_phase_drafts
+ADD CONSTRAINT unique_user_phase_repetition_draft
+UNIQUE (user_id, repetition_id, phase_name, status)
+DEFERRABLE INITIALLY DEFERRED;
+
+-- Add function to prevent withdrawal of submitted drafts when repetition is locked
+CREATE OR REPLACE FUNCTION public.check_repetition_lock_before_withdrawal()
+RETURNS TRIGGER AS $$
+BEGIN
+ -- Check if repetition is locked when trying to withdraw a submitted draft
+ IF NEW.status = 'withdrawn' AND OLD.status = 'submitted' THEN
+ IF EXISTS (
+ SELECT 1 FROM public.experiment_repetitions
+ WHERE id = NEW.repetition_id AND is_locked = true
+ ) THEN
+ RAISE EXCEPTION 'Cannot withdraw submitted draft: repetition is locked by admin';
+ END IF;
+ END IF;
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER check_lock_before_withdrawal
+ BEFORE UPDATE ON public.experiment_phase_drafts
+ FOR EACH ROW
+ EXECUTE FUNCTION public.check_repetition_lock_before_withdrawal();
diff --git a/supabase/migrations/20250725000003_fix_draft_constraints.sql b/supabase/migrations/20250725000003_fix_draft_constraints.sql
new file mode 100644
index 0000000..cc0542e
--- /dev/null
+++ b/supabase/migrations/20250725000003_fix_draft_constraints.sql
@@ -0,0 +1,17 @@
+-- Fix Draft Constraints Migration
+-- Allows multiple drafts per phase while preventing multiple submitted drafts
+
+-- Drop the overly restrictive constraint
+ALTER TABLE public.experiment_phase_drafts
+DROP CONSTRAINT IF EXISTS unique_user_phase_repetition_draft;
+
+-- Add a proper constraint that only prevents multiple submitted drafts
+-- Users can have multiple drafts in 'draft' or 'withdrawn' status, but only one 'submitted' per phase
+ALTER TABLE public.experiment_phase_drafts
+ADD CONSTRAINT unique_submitted_draft_per_user_phase
+EXCLUDE (user_id WITH =, repetition_id WITH =, phase_name WITH =)
+WHERE (status = 'submitted');
+
+-- Add comment explaining the constraint
+COMMENT ON CONSTRAINT unique_submitted_draft_per_user_phase ON public.experiment_phase_drafts
+IS 'Ensures only one submitted draft per user per phase per repetition, but allows multiple draft/withdrawn entries';
diff --git a/supabase/seed.sql b/supabase/seed.sql
index 80a4faa..7448705 100644
--- a/supabase/seed.sql
+++ b/supabase/seed.sql
@@ -1,6 +1,9 @@
--- Seed data for testing experiment scheduling functionality
+-- Seed data for testing experiment repetitions functionality
--- Insert some sample experiments for testing
+-- Insert experiments from phase_2_experimental_run_sheet.csv
+-- These are experiment blueprints/templates with their parameters
+-- Using run_number from CSV as experiment_number in database
+-- Note: Some run_numbers are duplicated in the CSV, so we'll only insert unique ones
INSERT INTO public.experiments (
experiment_number,
reps_required,
@@ -10,51 +13,118 @@ INSERT INTO public.experiments (
throughput_rate_pecans_sec,
crush_amount_in,
entry_exit_height_diff_in,
- schedule_status,
results_status,
created_by
-) VALUES
-(
- 1001,
- 5,
- 2.5,
- 30,
- 50.0,
- 2.5,
- 0.005,
- 1.2,
- 'pending schedule',
- 'valid',
- (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')
-),
-(
- 1002,
- 3,
- 1.0,
- 15,
- 45.0,
- 3.0,
- 0.003,
- 0.8,
- 'pending schedule',
- 'valid',
- (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')
-),
-(
- 1003,
- 4,
- 3.0,
- 45,
- 55.0,
- 2.0,
- 0.007,
- 1.5,
- 'scheduled',
- 'valid',
- (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')
-);
+) VALUES
+-- Unique experiments based on run_number from CSV
+(0, 3, 34, 19, 53, 28, 0.05, -0.09, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+(1, 3, 24, 27, 34, 29, 0.03, 0.01, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+(2, 3, 38, 10, 60, 28, 0.06, -0.1, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+(3, 3, 11, 36, 42, 13, 0.07, -0.07, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+(4, 3, 13, 41, 41, 38, 0.05, 0.03, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+(5, 3, 30, 33, 30, 36, 0.05, -0.04, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+(6, 3, 10, 22, 37, 30, 0.06, 0.02, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+(7, 3, 15, 30, 35, 32, 0.05, -0.07, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+(8, 3, 27, 12, 55, 24, 0.04, 0.04, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+(9, 3, 32, 26, 47, 26, 0.07, 0.03, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+(10, 3, 26, 60, 44, 12, 0.08, -0.1, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+(11, 3, 24, 59, 42, 25, 0.07, -0.05, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+(12, 3, 28, 59, 37, 23, 0.06, -0.08, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+(13, 3, 21, 59, 41, 21, 0.06, -0.09, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+(14, 3, 22, 59, 45, 17, 0.07, -0.08, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+(15, 3, 16, 60, 30, 24, 0.07, 0.02, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+(16, 3, 20, 59, 41, 14, 0.07, 0.04, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+(17, 3, 34, 60, 34, 29, 0.07, -0.09, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+(18, 3, 18, 49, 38, 35, 0.07, -0.08, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+(19, 3, 11, 25, 56, 34, 0.06, -0.09, 'valid', (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com'));
--- Update one experiment to have a scheduled date for testing
-UPDATE public.experiments
-SET scheduled_date = NOW() + INTERVAL '2 days'
-WHERE experiment_number = 1003;
+-- Create repetitions for all experiments based on CSV data
+-- Each experiment has 3 repetitions as specified in the CSV
+INSERT INTO public.experiment_repetitions (
+ experiment_id,
+ repetition_number,
+ schedule_status,
+ scheduled_date,
+ completion_status,
+ created_by
+) VALUES
+-- Experiment 0 repetitions
+((SELECT id FROM public.experiments WHERE experiment_number = 0), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 0), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 0), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+-- Experiment 1 repetitions
+((SELECT id FROM public.experiments WHERE experiment_number = 1), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 1), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 1), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+-- Experiment 2 repetitions
+((SELECT id FROM public.experiments WHERE experiment_number = 2), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 2), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 2), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+-- Experiment 3 repetitions
+((SELECT id FROM public.experiments WHERE experiment_number = 3), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 3), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 3), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+-- Experiment 4 repetitions
+((SELECT id FROM public.experiments WHERE experiment_number = 4), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 4), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 4), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+-- Experiment 5 repetitions
+((SELECT id FROM public.experiments WHERE experiment_number = 5), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 5), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 5), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+-- Experiment 6 repetitions
+((SELECT id FROM public.experiments WHERE experiment_number = 6), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 6), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 6), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+-- Experiment 7 repetitions
+((SELECT id FROM public.experiments WHERE experiment_number = 7), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 7), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 7), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+-- Experiment 8 repetitions
+((SELECT id FROM public.experiments WHERE experiment_number = 8), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 8), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 8), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+-- Experiment 9 repetitions
+((SELECT id FROM public.experiments WHERE experiment_number = 9), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 9), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 9), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+-- Experiment 10 repetitions
+((SELECT id FROM public.experiments WHERE experiment_number = 10), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 10), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 10), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+-- Experiment 11 repetitions
+((SELECT id FROM public.experiments WHERE experiment_number = 11), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 11), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 11), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+-- Experiment 12 repetitions
+((SELECT id FROM public.experiments WHERE experiment_number = 12), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 12), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 12), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+-- Experiment 13 repetitions
+((SELECT id FROM public.experiments WHERE experiment_number = 13), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 13), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 13), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+-- Experiment 14 repetitions
+((SELECT id FROM public.experiments WHERE experiment_number = 14), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 14), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 14), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+-- Experiment 15 repetitions
+((SELECT id FROM public.experiments WHERE experiment_number = 15), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 15), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 15), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+-- Experiment 16 repetitions
+((SELECT id FROM public.experiments WHERE experiment_number = 16), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 16), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 16), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+-- Experiment 17 repetitions
+((SELECT id FROM public.experiments WHERE experiment_number = 17), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 17), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 17), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+-- Experiment 18 repetitions
+((SELECT id FROM public.experiments WHERE experiment_number = 18), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 18), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 18), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+-- Experiment 19 repetitions
+((SELECT id FROM public.experiments WHERE experiment_number = 19), 1, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 19), 2, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')),
+((SELECT id FROM public.experiments WHERE experiment_number = 19), 3, 'pending schedule', NULL, false, (SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com'));
\ No newline at end of file
|