Refactor Experiment components to support new experiment book structure

- Updated ExperimentForm to handle additional phase parameters and improved initial state management.
- Modified ExperimentModal to fetch experiment data with phase configuration and ensure unique experiment numbers within the same phase.
- Renamed references from "phases" to "books" across ExperimentPhases, PhaseExperiments, and related components for consistency with the new terminology.
- Enhanced error handling and validation for new shelling parameters in ExperimentForm.
- Updated Supabase interface definitions to reflect changes in experiment and phase data structures.
This commit is contained in:
salirezav
2026-03-09 12:43:23 -04:00
parent 38a7846e7b
commit 0a2b24fdbf
14 changed files with 899 additions and 92 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -64,9 +64,9 @@ supabase gen types typescript --local > management-dashboard-web-app/src/types/s
## Seed Data
Seed files are run automatically after migrations when using docker-compose. They populate the database with initial data:
- `seed_01_users.sql`: Creates admin user and initial user profiles
- `seed_02_phase2_experiments.sql`: Creates initial experiment data
Seed files are run automatically after migrations when using `supabase db reset` (see `config.toml` → `[db.seed]` → `sql_paths`). Currently only user seed is enabled:
- `seed_01_users.sql`: Creates admin user and initial user profiles (enabled)
- `seed_02_phase2_experiments.sql`: Initial experiment data (temporarily disabled; add back to `sql_paths` in `config.toml` to re-enable)
## Configuration

View File

@@ -57,7 +57,9 @@ schema_paths = []
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed_01_users.sql", "./seed_02_phase2_experiments.sql"]
# Temporarily only user seed; other seeds suppressed.
sql_paths = ["./seed_01_users.sql"]
# sql_paths = ["./seed_01_users.sql", "./seed_02_phase2_experiments.sql"]
# , "./seed_04_phase2_jc_experiments.sql", "./seed_05_meyer_experiments.sql"]
[db.network_restrictions]

View File

@@ -0,0 +1,9 @@
-- Add experiment-level shelling parameters (defaults for repetitions)
-- These match the shelling table attributes: ring_gap_inches, drum_rpm
ALTER TABLE public.experiments
ADD COLUMN IF NOT EXISTS ring_gap_inches NUMERIC(6,2) CHECK (ring_gap_inches IS NULL OR ring_gap_inches > 0),
ADD COLUMN IF NOT EXISTS drum_rpm INTEGER CHECK (drum_rpm IS NULL OR drum_rpm > 0);
COMMENT ON COLUMN public.experiments.ring_gap_inches IS 'Default space (inches) between sheller rings for this experiment';
COMMENT ON COLUMN public.experiments.drum_rpm IS 'Default sheller drum revolutions per minute for this experiment';

View File

@@ -0,0 +1,399 @@
-- Rename table experiment_phases to experiment_books
-- This migration renames the table and updates all dependent objects (views, functions, triggers, indexes, RLS).
-- =============================================
-- 1. RENAME TABLE
-- =============================================
ALTER TABLE public.experiment_phases RENAME TO experiment_books;
-- =============================================
-- 2. RENAME TRIGGER
-- =============================================
DROP TRIGGER IF EXISTS set_updated_at_experiment_phases ON public.experiment_books;
CREATE TRIGGER set_updated_at_experiment_books
BEFORE UPDATE ON public.experiment_books
FOR EACH ROW
EXECUTE FUNCTION public.handle_updated_at();
-- =============================================
-- 3. RENAME CONSTRAINT
-- =============================================
ALTER TABLE public.experiment_books
RENAME CONSTRAINT ck_experiment_phases_machine_required_when_cracking
TO ck_experiment_books_machine_required_when_cracking;
-- =============================================
-- 4. RENAME INDEXES
-- =============================================
ALTER INDEX IF EXISTS public.idx_experiment_phases_name RENAME TO idx_experiment_books_name;
ALTER INDEX IF EXISTS public.idx_experiment_phases_cracking_machine_type_id RENAME TO idx_experiment_books_cracking_machine_type_id;
-- =============================================
-- 5. RLS POLICIES (drop old, create new with updated names)
-- =============================================
DROP POLICY IF EXISTS "Experiment phases are viewable by authenticated users" ON public.experiment_books;
DROP POLICY IF EXISTS "Experiment phases are insertable by authenticated users" ON public.experiment_books;
DROP POLICY IF EXISTS "Experiment phases are updatable by authenticated users" ON public.experiment_books;
DROP POLICY IF EXISTS "Experiment phases are deletable by authenticated users" ON public.experiment_books;
CREATE POLICY "Experiment books are viewable by authenticated users" ON public.experiment_books
FOR SELECT USING (auth.role() = 'authenticated');
CREATE POLICY "Experiment books are insertable by authenticated users" ON public.experiment_books
FOR INSERT WITH CHECK (auth.role() = 'authenticated');
CREATE POLICY "Experiment books are updatable by authenticated users" ON public.experiment_books
FOR UPDATE USING (auth.role() = 'authenticated');
CREATE POLICY "Experiment books are deletable by authenticated users" ON public.experiment_books
FOR DELETE USING (auth.role() = 'authenticated');
-- =============================================
-- 6. UPDATE FUNCTION: create_phase_executions_for_repetition (references experiment_phases)
-- =============================================
CREATE OR REPLACE FUNCTION create_phase_executions_for_repetition()
RETURNS TRIGGER AS $$
DECLARE
exp_phase_config RECORD;
phase_type_list TEXT[] := ARRAY[]::TEXT[];
phase_name TEXT;
BEGIN
SELECT
ep.has_soaking,
ep.has_airdrying,
ep.has_cracking,
ep.has_shelling,
ep.cracking_machine_type_id
INTO exp_phase_config
FROM public.experiments e
JOIN public.experiment_books ep ON e.phase_id = ep.id
WHERE e.id = NEW.experiment_id;
IF exp_phase_config.has_soaking THEN
phase_type_list := array_append(phase_type_list, 'soaking');
END IF;
IF exp_phase_config.has_airdrying THEN
phase_type_list := array_append(phase_type_list, 'airdrying');
END IF;
IF exp_phase_config.has_cracking THEN
phase_type_list := array_append(phase_type_list, 'cracking');
END IF;
IF exp_phase_config.has_shelling THEN
phase_type_list := array_append(phase_type_list, 'shelling');
END IF;
FOREACH phase_name IN ARRAY phase_type_list
LOOP
INSERT INTO public.experiment_phase_executions (
repetition_id,
phase_type,
scheduled_start_time,
status,
created_by,
soaking_duration_minutes,
duration_minutes,
machine_type_id
)
VALUES (
NEW.id,
phase_name,
NOW(),
'pending',
NEW.created_by,
NULL,
NULL,
CASE WHEN phase_name = 'cracking'
THEN exp_phase_config.cracking_machine_type_id
ELSE NULL END
);
END LOOP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- =============================================
-- 7. UPDATE FUNCTION: create_sample_experiment_phases (INSERT into experiment_books)
-- =============================================
CREATE OR REPLACE FUNCTION public.create_sample_experiment_phases()
RETURNS VOID AS $$
DECLARE
jc_cracker_id UUID;
meyer_cracker_id UUID;
BEGIN
SELECT id INTO jc_cracker_id FROM public.machine_types WHERE name = 'JC Cracker';
SELECT id INTO meyer_cracker_id FROM public.machine_types WHERE name = 'Meyer Cracker';
INSERT INTO public.experiment_books (name, description, has_soaking, has_airdrying, has_cracking, has_shelling, cracking_machine_type_id, created_by) VALUES
('Full Process - JC Cracker', 'Complete pecan processing with JC Cracker', true, true, true, true, jc_cracker_id, (SELECT id FROM public.user_profiles LIMIT 1)),
('Full Process - Meyer Cracker', 'Complete pecan processing with Meyer Cracker', true, true, true, true, meyer_cracker_id, (SELECT id FROM public.user_profiles LIMIT 1)),
('Cracking Only - JC Cracker', 'JC Cracker cracking process only', false, false, true, false, jc_cracker_id, (SELECT id FROM public.user_profiles LIMIT 1)),
('Cracking Only - Meyer Cracker', 'Meyer Cracker cracking process only', false, false, true, false, meyer_cracker_id, (SELECT id FROM public.user_profiles LIMIT 1))
ON CONFLICT (name) DO NOTHING;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- =============================================
-- 8. UPDATE VIEWS (from 00014 - experiments_with_phases, repetitions_with_phases, experiments_with_all_reps_and_phases, get_experiment_with_reps_and_phases)
-- =============================================
CREATE OR REPLACE VIEW public.experiments_with_phases AS
SELECT
e.id,
e.experiment_number,
e.reps_required,
e.weight_per_repetition_lbs,
e.results_status,
e.completion_status,
e.phase_id,
e.created_at,
e.updated_at,
e.created_by,
ep.name as phase_name,
ep.description as phase_description,
ep.has_soaking,
ep.has_airdrying,
ep.has_cracking,
ep.has_shelling,
er.id as first_repetition_id,
er.repetition_number as first_repetition_number,
soaking_e.id as soaking_id,
soaking_e.scheduled_start_time as soaking_scheduled_start,
soaking_e.actual_start_time as soaking_actual_start,
soaking_e.soaking_duration_minutes,
soaking_e.scheduled_end_time as soaking_scheduled_end,
soaking_e.actual_end_time as soaking_actual_end,
airdrying_e.id as airdrying_id,
airdrying_e.scheduled_start_time as airdrying_scheduled_start,
airdrying_e.actual_start_time as airdrying_actual_start,
airdrying_e.duration_minutes as airdrying_duration,
airdrying_e.scheduled_end_time as airdrying_scheduled_end,
airdrying_e.actual_end_time as airdrying_actual_end,
cracking_e.id as cracking_id,
cracking_e.scheduled_start_time as cracking_scheduled_start,
cracking_e.actual_start_time as cracking_actual_start,
cracking_e.actual_end_time as cracking_actual_end,
mt.name as machine_type_name,
shelling_e.id as shelling_id,
shelling_e.scheduled_start_time as shelling_scheduled_start,
shelling_e.actual_start_time as shelling_actual_start,
shelling_e.actual_end_time as shelling_actual_end
FROM public.experiments e
LEFT JOIN public.experiment_books ep ON e.phase_id = ep.id
LEFT JOIN LATERAL (
SELECT id, repetition_number
FROM public.experiment_repetitions
WHERE experiment_id = e.id
ORDER BY repetition_number
LIMIT 1
) er ON true
LEFT JOIN public.experiment_phase_executions soaking_e
ON soaking_e.repetition_id = er.id AND soaking_e.phase_type = 'soaking'
LEFT JOIN public.experiment_phase_executions airdrying_e
ON airdrying_e.repetition_id = er.id AND airdrying_e.phase_type = 'airdrying'
LEFT JOIN public.experiment_phase_executions cracking_e
ON cracking_e.repetition_id = er.id AND cracking_e.phase_type = 'cracking'
LEFT JOIN public.experiment_phase_executions shelling_e
ON shelling_e.repetition_id = er.id AND shelling_e.phase_type = 'shelling'
LEFT JOIN public.machine_types mt ON cracking_e.machine_type_id = mt.id;
CREATE OR REPLACE VIEW public.repetitions_with_phases AS
SELECT
er.id,
er.experiment_id,
er.repetition_number,
er.status,
er.created_at,
er.updated_at,
er.created_by,
e.experiment_number,
e.phase_id,
e.weight_per_repetition_lbs,
ep.name as phase_name,
ep.has_soaking,
ep.has_airdrying,
ep.has_cracking,
ep.has_shelling,
soaking_e.scheduled_start_time as soaking_scheduled_start,
soaking_e.actual_start_time as soaking_actual_start,
soaking_e.soaking_duration_minutes,
soaking_e.scheduled_end_time as soaking_scheduled_end,
soaking_e.actual_end_time as soaking_actual_end,
airdrying_e.scheduled_start_time as airdrying_scheduled_start,
airdrying_e.actual_start_time as airdrying_actual_start,
airdrying_e.duration_minutes as airdrying_duration,
airdrying_e.scheduled_end_time as airdrying_scheduled_end,
airdrying_e.actual_end_time as airdrying_actual_end,
cracking_e.scheduled_start_time as cracking_scheduled_start,
cracking_e.actual_start_time as cracking_actual_start,
cracking_e.actual_end_time as cracking_actual_end,
mt.name as machine_type_name,
shelling_e.scheduled_start_time as shelling_scheduled_start,
shelling_e.actual_start_time as shelling_actual_start,
shelling_e.actual_end_time as shelling_actual_end
FROM public.experiment_repetitions er
JOIN public.experiments e ON er.experiment_id = e.id
LEFT JOIN public.experiment_books ep ON e.phase_id = ep.id
LEFT JOIN public.experiment_phase_executions soaking_e
ON er.id = soaking_e.repetition_id AND soaking_e.phase_type = 'soaking'
LEFT JOIN public.experiment_phase_executions airdrying_e
ON er.id = airdrying_e.repetition_id AND airdrying_e.phase_type = 'airdrying'
LEFT JOIN public.experiment_phase_executions cracking_e
ON er.id = cracking_e.repetition_id AND cracking_e.phase_type = 'cracking'
LEFT JOIN public.experiment_phase_executions shelling_e
ON er.id = shelling_e.repetition_id AND shelling_e.phase_type = 'shelling'
LEFT JOIN public.machine_types mt ON cracking_e.machine_type_id = mt.id;
-- experiments_with_all_reps_and_phases
CREATE OR REPLACE VIEW public.experiments_with_all_reps_and_phases AS
SELECT
e.id as experiment_id,
e.experiment_number,
e.reps_required,
e.weight_per_repetition_lbs,
e.results_status,
e.completion_status,
e.phase_id,
e.created_at as experiment_created_at,
e.updated_at as experiment_updated_at,
e.created_by as experiment_created_by,
ep.name as phase_name,
ep.description as phase_description,
ep.has_soaking,
ep.has_airdrying,
ep.has_cracking,
ep.has_shelling,
ep.cracking_machine_type_id as phase_cracking_machine_type_id,
er.id as repetition_id,
er.repetition_number,
er.status as repetition_status,
er.scheduled_date,
er.created_at as repetition_created_at,
er.updated_at as repetition_updated_at,
er.created_by as repetition_created_by,
soaking_e.id as soaking_execution_id,
soaking_e.scheduled_start_time as soaking_scheduled_start,
soaking_e.actual_start_time as soaking_actual_start,
soaking_e.soaking_duration_minutes,
soaking_e.scheduled_end_time as soaking_scheduled_end,
soaking_e.actual_end_time as soaking_actual_end,
soaking_e.status as soaking_status,
airdrying_e.id as airdrying_execution_id,
airdrying_e.scheduled_start_time as airdrying_scheduled_start,
airdrying_e.actual_start_time as airdrying_actual_start,
airdrying_e.duration_minutes as airdrying_duration_minutes,
airdrying_e.scheduled_end_time as airdrying_scheduled_end,
airdrying_e.actual_end_time as airdrying_actual_end,
airdrying_e.status as airdrying_status,
cracking_e.id as cracking_execution_id,
cracking_e.scheduled_start_time as cracking_scheduled_start,
cracking_e.actual_start_time as cracking_actual_start,
cracking_e.scheduled_end_time as cracking_scheduled_end,
cracking_e.actual_end_time as cracking_actual_end,
cracking_e.machine_type_id as cracking_machine_type_id,
cracking_e.status as cracking_status,
mt.name as machine_type_name,
shelling_e.id as shelling_execution_id,
shelling_e.scheduled_start_time as shelling_scheduled_start,
shelling_e.actual_start_time as shelling_actual_start,
shelling_e.scheduled_end_time as shelling_scheduled_end,
shelling_e.actual_end_time as shelling_actual_end,
shelling_e.status as shelling_status
FROM public.experiments e
LEFT JOIN public.experiment_books ep ON e.phase_id = ep.id
LEFT JOIN public.experiment_repetitions er ON er.experiment_id = e.id
LEFT JOIN public.experiment_phase_executions soaking_e
ON soaking_e.repetition_id = er.id AND soaking_e.phase_type = 'soaking'
LEFT JOIN public.experiment_phase_executions airdrying_e
ON airdrying_e.repetition_id = er.id AND airdrying_e.phase_type = 'airdrying'
LEFT JOIN public.experiment_phase_executions cracking_e
ON cracking_e.repetition_id = er.id AND cracking_e.phase_type = 'cracking'
LEFT JOIN public.experiment_phase_executions shelling_e
ON shelling_e.repetition_id = er.id AND shelling_e.phase_type = 'shelling'
LEFT JOIN public.machine_types mt ON cracking_e.machine_type_id = mt.id
ORDER BY e.experiment_number, er.repetition_number;
-- get_experiment_with_reps_and_phases function
CREATE OR REPLACE FUNCTION public.get_experiment_with_reps_and_phases(p_experiment_id UUID)
RETURNS TABLE (
experiment_id UUID,
experiment_number INTEGER,
phase_name TEXT,
repetitions JSONB
) AS $$
BEGIN
RETURN QUERY
SELECT
e.id,
e.experiment_number,
ep.name,
COALESCE(
jsonb_agg(
jsonb_build_object(
'repetition_id', er.id,
'repetition_number', er.repetition_number,
'status', er.status,
'scheduled_date', er.scheduled_date,
'soaking', jsonb_build_object(
'scheduled_start', soaking_e.scheduled_start_time,
'actual_start', soaking_e.actual_start_time,
'duration_minutes', soaking_e.soaking_duration_minutes,
'scheduled_end', soaking_e.scheduled_end_time,
'actual_end', soaking_e.actual_end_time,
'status', soaking_e.status
),
'airdrying', jsonb_build_object(
'scheduled_start', airdrying_e.scheduled_start_time,
'actual_start', airdrying_e.actual_start_time,
'duration_minutes', airdrying_e.duration_minutes,
'scheduled_end', airdrying_e.scheduled_end_time,
'actual_end', airdrying_e.actual_end_time,
'status', airdrying_e.status
),
'cracking', jsonb_build_object(
'scheduled_start', cracking_e.scheduled_start_time,
'actual_start', cracking_e.actual_start_time,
'scheduled_end', cracking_e.scheduled_end_time,
'actual_end', cracking_e.actual_end_time,
'machine_type_id', cracking_e.machine_type_id,
'machine_type_name', mt.name,
'status', cracking_e.status
),
'shelling', jsonb_build_object(
'scheduled_start', shelling_e.scheduled_start_time,
'actual_start', shelling_e.actual_start_time,
'scheduled_end', shelling_e.scheduled_end_time,
'actual_end', shelling_e.actual_end_time,
'status', shelling_e.status
)
)
ORDER BY er.repetition_number
),
'[]'::jsonb
) as repetitions
FROM public.experiments e
LEFT JOIN public.experiment_books ep ON e.phase_id = ep.id
LEFT JOIN public.experiment_repetitions er ON er.experiment_id = e.id
LEFT JOIN public.experiment_phase_executions soaking_e
ON soaking_e.repetition_id = er.id AND soaking_e.phase_type = 'soaking'
LEFT JOIN public.experiment_phase_executions airdrying_e
ON airdrying_e.repetition_id = er.id AND airdrying_e.phase_type = 'airdrying'
LEFT JOIN public.experiment_phase_executions cracking_e
ON cracking_e.repetition_id = er.id AND cracking_e.phase_type = 'cracking'
LEFT JOIN public.experiment_phase_executions shelling_e
ON shelling_e.repetition_id = er.id AND shelling_e.phase_type = 'shelling'
LEFT JOIN public.machine_types mt ON cracking_e.machine_type_id = mt.id
WHERE e.id = p_experiment_id
GROUP BY e.id, e.experiment_number, ep.name;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
GRANT SELECT ON public.experiments_with_all_reps_and_phases TO authenticated;
GRANT EXECUTE ON FUNCTION public.get_experiment_with_reps_and_phases(UUID) TO authenticated;

View File

@@ -0,0 +1,118 @@
-- Experiment-level phase config tables
-- One row per experiment per phase; linked by experiment_id. Used when creating an experiment
-- so soaking, airdrying, cracking, and shelling parameters are stored and can be applied to repetitions.
-- =============================================
-- 1. EXPERIMENT_SOAKING (template for soaking phase)
-- =============================================
CREATE TABLE IF NOT EXISTS public.experiment_soaking (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
experiment_id UUID NOT NULL REFERENCES public.experiments(id) ON DELETE CASCADE,
soaking_duration_hr DOUBLE PRECISION NOT NULL CHECK (soaking_duration_hr >= 0),
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),
CONSTRAINT unique_experiment_soaking_per_experiment UNIQUE (experiment_id)
);
CREATE INDEX IF NOT EXISTS idx_experiment_soaking_experiment_id ON public.experiment_soaking(experiment_id);
GRANT ALL ON public.experiment_soaking TO authenticated;
ALTER TABLE public.experiment_soaking ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Experiment soaking config is viewable by authenticated" ON public.experiment_soaking FOR SELECT USING (auth.role() = 'authenticated');
CREATE POLICY "Experiment soaking config is insertable by authenticated" ON public.experiment_soaking FOR INSERT WITH CHECK (auth.role() = 'authenticated');
CREATE POLICY "Experiment soaking config is updatable by authenticated" ON public.experiment_soaking FOR UPDATE USING (auth.role() = 'authenticated');
CREATE POLICY "Experiment soaking config is deletable by authenticated" ON public.experiment_soaking FOR DELETE USING (auth.role() = 'authenticated');
CREATE TRIGGER set_updated_at_experiment_soaking
BEFORE UPDATE ON public.experiment_soaking
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
-- =============================================
-- 2. EXPERIMENT_AIRDRYING (template for airdrying phase)
-- =============================================
CREATE TABLE IF NOT EXISTS public.experiment_airdrying (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
experiment_id UUID NOT NULL REFERENCES public.experiments(id) ON DELETE CASCADE,
duration_minutes INTEGER NOT NULL CHECK (duration_minutes >= 0),
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),
CONSTRAINT unique_experiment_airdrying_per_experiment UNIQUE (experiment_id)
);
CREATE INDEX IF NOT EXISTS idx_experiment_airdrying_experiment_id ON public.experiment_airdrying(experiment_id);
GRANT ALL ON public.experiment_airdrying TO authenticated;
ALTER TABLE public.experiment_airdrying ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Experiment airdrying config is viewable by authenticated" ON public.experiment_airdrying FOR SELECT USING (auth.role() = 'authenticated');
CREATE POLICY "Experiment airdrying config is insertable by authenticated" ON public.experiment_airdrying FOR INSERT WITH CHECK (auth.role() = 'authenticated');
CREATE POLICY "Experiment airdrying config is updatable by authenticated" ON public.experiment_airdrying FOR UPDATE USING (auth.role() = 'authenticated');
CREATE POLICY "Experiment airdrying config is deletable by authenticated" ON public.experiment_airdrying FOR DELETE USING (auth.role() = 'authenticated');
CREATE TRIGGER set_updated_at_experiment_airdrying
BEFORE UPDATE ON public.experiment_airdrying
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
-- =============================================
-- 3. EXPERIMENT_CRACKING (template for cracking; supports JC and Meyer params)
-- =============================================
CREATE TABLE IF NOT EXISTS public.experiment_cracking (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
experiment_id UUID NOT NULL REFERENCES public.experiments(id) ON DELETE CASCADE,
machine_type_id UUID NOT NULL REFERENCES public.machine_types(id) ON DELETE RESTRICT,
-- JC Cracker parameters (nullable; used when machine is JC)
plate_contact_frequency_hz DOUBLE PRECISION CHECK (plate_contact_frequency_hz IS NULL OR plate_contact_frequency_hz > 0),
throughput_rate_pecans_sec DOUBLE PRECISION CHECK (throughput_rate_pecans_sec IS NULL OR throughput_rate_pecans_sec > 0),
crush_amount_in DOUBLE PRECISION CHECK (crush_amount_in IS NULL OR crush_amount_in >= 0),
entry_exit_height_diff_in DOUBLE PRECISION,
-- Meyer Cracker parameters (nullable; used when machine is Meyer)
motor_speed_hz DOUBLE PRECISION CHECK (motor_speed_hz IS NULL OR motor_speed_hz > 0),
jig_displacement_inches DOUBLE PRECISION CHECK (jig_displacement_inches IS NULL OR jig_displacement_inches >= 0),
spring_stiffness_nm DOUBLE PRECISION CHECK (spring_stiffness_nm IS NULL OR spring_stiffness_nm > 0),
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),
CONSTRAINT unique_experiment_cracking_per_experiment UNIQUE (experiment_id)
);
CREATE INDEX IF NOT EXISTS idx_experiment_cracking_experiment_id ON public.experiment_cracking(experiment_id);
CREATE INDEX IF NOT EXISTS idx_experiment_cracking_machine_type_id ON public.experiment_cracking(machine_type_id);
GRANT ALL ON public.experiment_cracking TO authenticated;
ALTER TABLE public.experiment_cracking ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Experiment cracking config is viewable by authenticated" ON public.experiment_cracking FOR SELECT USING (auth.role() = 'authenticated');
CREATE POLICY "Experiment cracking config is insertable by authenticated" ON public.experiment_cracking FOR INSERT WITH CHECK (auth.role() = 'authenticated');
CREATE POLICY "Experiment cracking config is updatable by authenticated" ON public.experiment_cracking FOR UPDATE USING (auth.role() = 'authenticated');
CREATE POLICY "Experiment cracking config is deletable by authenticated" ON public.experiment_cracking FOR DELETE USING (auth.role() = 'authenticated');
CREATE TRIGGER set_updated_at_experiment_cracking
BEFORE UPDATE ON public.experiment_cracking
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
-- =============================================
-- 4. EXPERIMENT_SHELLING (template for shelling phase)
-- =============================================
CREATE TABLE IF NOT EXISTS public.experiment_shelling (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
experiment_id UUID NOT NULL REFERENCES public.experiments(id) ON DELETE CASCADE,
ring_gap_inches NUMERIC(6,2) CHECK (ring_gap_inches IS NULL OR ring_gap_inches > 0),
drum_rpm INTEGER CHECK (drum_rpm IS NULL OR drum_rpm > 0),
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),
CONSTRAINT unique_experiment_shelling_per_experiment UNIQUE (experiment_id)
);
CREATE INDEX IF NOT EXISTS idx_experiment_shelling_experiment_id ON public.experiment_shelling(experiment_id);
GRANT ALL ON public.experiment_shelling TO authenticated;
ALTER TABLE public.experiment_shelling ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Experiment shelling config is viewable by authenticated" ON public.experiment_shelling FOR SELECT USING (auth.role() = 'authenticated');
CREATE POLICY "Experiment shelling config is insertable by authenticated" ON public.experiment_shelling FOR INSERT WITH CHECK (auth.role() = 'authenticated');
CREATE POLICY "Experiment shelling config is updatable by authenticated" ON public.experiment_shelling FOR UPDATE USING (auth.role() = 'authenticated');
CREATE POLICY "Experiment shelling config is deletable by authenticated" ON public.experiment_shelling FOR DELETE USING (auth.role() = 'authenticated');
CREATE TRIGGER set_updated_at_experiment_shelling
BEFORE UPDATE ON public.experiment_shelling
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();

View File

@@ -566,11 +566,11 @@ INSERT INTO public.machine_types (name, description, created_by) VALUES
ON CONFLICT (name) DO NOTHING;
-- =============================================
-- 5. CREATE EXPERIMENT PHASES
-- 5. CREATE EXPERIMENT BOOKS (table renamed from experiment_phases in migration 00016)
-- =============================================
-- Create "Phase 2 of JC Experiments" phase
INSERT INTO public.experiment_phases (name, description, has_soaking, has_airdrying, has_cracking, has_shelling, cracking_machine_type_id, created_by)
-- Create "Phase 2 of JC Experiments" book
INSERT INTO public.experiment_books (name, description, has_soaking, has_airdrying, has_cracking, has_shelling, cracking_machine_type_id, created_by)
SELECT
'Phase 2 of JC Experiments',
'Second phase of JC Cracker experiments for pecan processing optimization',
@@ -584,8 +584,8 @@ FROM public.user_profiles up
WHERE up.email = 's.alireza.v@gmail.com'
;
-- Create "Post Workshop Meyer Experiments" phase
INSERT INTO public.experiment_phases (name, description, has_soaking, has_airdrying, has_cracking, has_shelling, cracking_machine_type_id, created_by)
-- Create "Post Workshop Meyer Experiments" book
INSERT INTO public.experiment_books (name, description, has_soaking, has_airdrying, has_cracking, has_shelling, cracking_machine_type_id, created_by)
SELECT
'Post Workshop Meyer Experiments',
'Post workshop Meyer Cracker experiments for pecan processing optimization',

View File

@@ -1,7 +1,9 @@
-- ==============================================
-- 6. CREATE EXPERIMENTS FOR PHASE 2
-- ==============================================
-- TEMPORARILY DISABLED (see config.toml sql_paths). When re-enabling, replace
-- all "experiment_phases" with "experiment_books" (table renamed in migration 00016).
--
-- This seed file creates experiments from phase_2_JC_experimental_run_sheet.csv
-- Each experiment has 3 repetitions with specific parameters
-- Experiment numbers are incremented by 1 (CSV 0-19 becomes DB 1-20)