WIP: integrate-old-refactors-of-github #1

Draft
hdh20267 wants to merge 140 commits from integrate-old-refactors-of-github into main
8 changed files with 1101 additions and 773 deletions
Showing only changes of commit 5da5347443 - Show all commits

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@
"build:watch": "vite build --watch",
"serve:dist": "serve -s dist -l 3003",
"preview": "vite preview --port 3003",
"dev:watch": "npm run build && (npm run build:watch &) && sleep 1 && npx http-server dist -p 3003 --cors -c-1"
"dev:watch": "./wait-and-serve.sh"
},
"dependencies": {
"@supabase/supabase-js": "^2.52.0",

File diff suppressed because it is too large Load Diff

View File

@@ -70,8 +70,6 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
// Track repetitions that have been dropped/moved and should show time points
const [repetitionsWithTimes, setRepetitionsWithTimes] = useState<Set<string>>(new Set())
// Track which repetitions are locked (prevent dragging)
const [lockedSchedules, setLockedSchedules] = useState<Set<string>>(new Set())
// Track which repetitions are currently being scheduled
const [schedulingRepetitions, setSchedulingRepetitions] = useState<Set<string>>(new Set())
// Track conductor assignments for each phase marker (markerId -> conductorIds[])
@@ -253,44 +251,22 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
}
const toggleRepetition = (repId: string) => {
// Checking/unchecking should only control visibility on the timeline.
// It must NOT clear scheduling info or conductor assignments.
setSelectedRepetitionIds(prev => {
const next = new Set(prev)
if (next.has(repId)) {
// Hide this repetition from the timeline
next.delete(repId)
// Remove from scheduled repetitions when unchecked
setScheduledRepetitions(prevScheduled => {
const newScheduled = { ...prevScheduled }
delete newScheduled[repId]
return newScheduled
})
// Clear all related state when unchecked
setRepetitionsWithTimes(prev => {
const next = new Set(prev)
next.delete(repId)
return next
})
setLockedSchedules(prev => {
const next = new Set(prev)
next.delete(repId)
return next
})
setSchedulingRepetitions(prev => {
const next = new Set(prev)
next.delete(repId)
return next
})
// Re-stagger remaining repetitions
const remainingIds = Array.from(next).filter(id => id !== repId)
if (remainingIds.length > 0) {
reStaggerRepetitions(remainingIds)
}
// Keep scheduledRepetitions and repetitionsWithTimes intact so that
// re-checking the box restores the repetition in the correct spot.
} else {
// Show this repetition on the timeline
next.add(repId)
// Auto-spawn when checked - pass the updated set to ensure correct stagger calculation
// spawnSingleRepetition will position the new repetition relative to existing ones
// without resetting existing positions
spawnSingleRepetition(repId, next)
// Re-stagger all existing repetitions to prevent overlap
// Note: reStaggerRepetitions will automatically skip locked repetitions
reStaggerRepetitions([...next, repId])
}
return next
})
@@ -305,20 +281,14 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
const allSelected = allRepetitions.every(rep => prev.has(rep.id))
if (allSelected) {
// Deselect all repetitions in this phase
// Deselect all repetitions in this phase (hide from timeline only)
const next = new Set(prev)
allRepetitions.forEach(rep => {
next.delete(rep.id)
// Remove from scheduled repetitions
setScheduledRepetitions(prevScheduled => {
const newScheduled = { ...prevScheduled }
delete newScheduled[rep.id]
return newScheduled
})
})
return next
} else {
// Select all repetitions in this phase
// Select all repetitions in this phase (show on timeline)
const next = new Set(prev)
allRepetitions.forEach(rep => {
next.add(rep.id)
@@ -356,7 +326,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
// Re-stagger all repetitions to prevent overlap
// IMPORTANT: Skip locked repetitions to prevent them from moving
const reStaggerRepetitions = useCallback((repIds: string[]) => {
const reStaggerRepetitions = useCallback((repIds: string[], onlyResetWithoutCustomTimes: boolean = false) => {
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
tomorrow.setHours(9, 0, 0, 0)
@@ -364,8 +334,11 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
setScheduledRepetitions(prev => {
const newScheduled = { ...prev }
// Filter out locked repetitions - they should not be moved
const unlockedRepIds = repIds.filter(repId => !lockedSchedules.has(repId))
// If onlyResetWithoutCustomTimes is true, filter out repetitions that have custom times set
let unlockedRepIds = repIds
if (onlyResetWithoutCustomTimes) {
unlockedRepIds = unlockedRepIds.filter(repId => !repetitionsWithTimes.has(repId))
}
// Calculate stagger index only for unlocked repetitions
let staggerIndex = 0
@@ -407,7 +380,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
return newScheduled
})
}, [lockedSchedules, repetitionsByExperiment, experimentsByPhase, soakingByExperiment, airdryingByExperiment])
}, [repetitionsByExperiment, experimentsByPhase, soakingByExperiment, airdryingByExperiment, repetitionsWithTimes])
// Spawn a single repetition in calendar
const spawnSingleRepetition = (repId: string, updatedSelectedIds?: Set<string>) => {
@@ -477,10 +450,11 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
let newScheduled = { ...prev }
const clampToReasonableHours = (d: Date) => {
// Allow full 24 hours (midnight to midnight)
const min = new Date(d)
min.setHours(5, 0, 0, 0)
min.setHours(0, 0, 0, 0)
const max = new Date(d)
max.setHours(23, 0, 0, 0)
max.setHours(23, 59, 59, 999)
const t = d.getTime()
return new Date(Math.min(Math.max(t, min.getTime()), max.getTime()))
}
@@ -536,13 +510,10 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
const repetition = repetitionsByExperiment[scheduled.experimentId]?.find(r => r.id === scheduled.repetitionId)
if (experiment && repetition && scheduled.soakingStart) {
const isLocked = lockedSchedules.has(scheduled.repetitionId)
const lockIcon = isLocked ? '🔒' : '🔓'
// Soaking marker
events.push({
id: `${scheduled.repetitionId}-soaking`,
title: `${lockIcon} 💧 Soaking - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
title: `💧 Soaking - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
start: scheduled.soakingStart,
end: new Date(scheduled.soakingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility
resource: 'soaking'
@@ -552,7 +523,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
if (scheduled.airdryingStart) {
events.push({
id: `${scheduled.repetitionId}-airdrying`,
title: `${lockIcon} 🌬️ Airdrying - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
title: `🌬️ Airdrying - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
start: scheduled.airdryingStart,
end: new Date(scheduled.airdryingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility
resource: 'airdrying'
@@ -563,7 +534,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
if (scheduled.crackingStart) {
events.push({
id: `${scheduled.repetitionId}-cracking`,
title: `${lockIcon} ⚡ Cracking - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
title: `⚡ Cracking - Exp ${experiment.experiment_number} Rep ${repetition.repetition_number}`,
start: scheduled.crackingStart,
end: new Date(scheduled.crackingStart.getTime() + 15 * 60000), // 15 minute duration for better visibility
resource: 'cracking'
@@ -573,7 +544,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
})
return events
}, [scheduledRepetitions, experimentsByPhase, repetitionsByExperiment, lockedSchedules])
}, [scheduledRepetitions, experimentsByPhase, repetitionsByExperiment])
// Memoize the calendar events
const calendarEvents = useMemo(() => {
@@ -609,15 +580,16 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
return moment(date).format('MMM D, h:mm A')
}
const toggleScheduleLock = (repId: string) => {
setLockedSchedules(prev => {
const next = new Set(prev)
if (next.has(repId)) {
next.delete(repId)
} else {
next.add(repId)
}
return next
// Remove all conductor assignments from a repetition
const removeRepetitionAssignments = (repId: string) => {
const markerIdPrefix = repId
setConductorAssignments(prev => {
const newAssignments = { ...prev }
// Remove assignments for all three phases
delete newAssignments[`${markerIdPrefix}-soaking`]
delete newAssignments[`${markerIdPrefix}-airdrying`]
delete newAssignments[`${markerIdPrefix}-cracking`]
return newAssignments
})
}
@@ -625,24 +597,16 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
// Only make repetition markers draggable, not availability events
const resource = event.resource as string
if (resource === 'soaking' || resource === 'airdrying' || resource === 'cracking') {
// Check if the repetition is locked
const eventId = event.id as string
const repId = eventId.split('-')[0]
const isLocked = lockedSchedules.has(repId)
return !isLocked
return true
}
return false
}, [lockedSchedules])
}, [])
const eventPropGetter = useCallback((event: any) => {
const resource = event.resource as string
// Styling for repetition markers (foreground events)
if (resource === 'soaking' || resource === 'airdrying' || resource === 'cracking') {
const eventId = event.id as string
const repId = eventId.split('-')[0]
const isLocked = lockedSchedules.has(repId)
const colors = {
soaking: '#3b82f6', // blue
airdrying: '#10b981', // green
@@ -652,8 +616,8 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
return {
style: {
backgroundColor: isLocked ? '#9ca3af' : color, // gray if locked
borderColor: isLocked ? color : color, // border takes original color when locked
backgroundColor: color,
borderColor: color,
color: 'white',
borderRadius: '8px',
border: '2px solid',
@@ -674,17 +638,17 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
cursor: isLocked ? 'not-allowed' : 'grab',
boxShadow: isLocked ? '0 1px 2px rgba(0,0,0,0.1)' : '0 2px 4px rgba(0,0,0,0.2)',
cursor: 'grab',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
transition: 'all 0.2s ease',
opacity: isLocked ? 0.7 : 1
opacity: 1
}
}
}
// Default styling for other events
return {}
}, [lockedSchedules])
}, [])
const scheduleRepetition = async (repId: string, experimentId: string) => {
setSchedulingRepetitions(prev => new Set(prev).add(repId))
@@ -756,6 +720,51 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
}
}
// Unschedule a repetition: clear its scheduling info and unassign all conductors.
const unscheduleRepetition = async (repId: string, experimentId: string) => {
setSchedulingRepetitions(prev => new Set(prev).add(repId))
try {
// Remove all conductor assignments for this repetition
removeRepetitionAssignments(repId)
// Clear scheduled_date on the repetition in local state
setRepetitionsByExperiment(prev => ({
...prev,
[experimentId]: prev[experimentId]?.map(r =>
r.id === repId ? { ...r, scheduled_date: null } : r
) || []
}))
// Clear scheduled times for this repetition so it disappears from the timeline
setScheduledRepetitions(prev => {
const next = { ...prev }
delete next[repId]
return next
})
// This repetition no longer has active times
setRepetitionsWithTimes(prev => {
const next = new Set(prev)
next.delete(repId)
return next
})
// Also clear scheduled_date in the database for this repetition
await repetitionManagement.updateRepetition(repId, {
scheduled_date: null
})
} catch (error: any) {
setError(error?.message || 'Failed to unschedule repetition')
} finally {
setSchedulingRepetitions(prev => {
const next = new Set(prev)
next.delete(repId)
return next
})
}
}
// Restore scroll position after scheduledRepetitions changes
useEffect(() => {
if (scrollPositionRef.current) {
@@ -806,11 +815,15 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
phase: 'soaking' | 'airdrying' | 'cracking'
startTime: Date
assignedConductors: string[]
locked: boolean
}> = []
Object.values(scheduledRepetitions).forEach(scheduled => {
const repId = scheduled.repetitionId
// Only include markers for repetitions that are checked (selected)
if (!selectedRepetitionIds.has(repId)) {
return
}
const markerIdPrefix = repId
if (scheduled.soakingStart) {
@@ -820,8 +833,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
experimentId: scheduled.experimentId,
phase: 'soaking',
startTime: scheduled.soakingStart,
assignedConductors: conductorAssignments[`${markerIdPrefix}-soaking`] || [],
locked: lockedSchedules.has(repId)
assignedConductors: conductorAssignments[`${markerIdPrefix}-soaking`] || []
})
}
@@ -832,8 +844,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
experimentId: scheduled.experimentId,
phase: 'airdrying',
startTime: scheduled.airdryingStart,
assignedConductors: conductorAssignments[`${markerIdPrefix}-airdrying`] || [],
locked: lockedSchedules.has(repId)
assignedConductors: conductorAssignments[`${markerIdPrefix}-airdrying`] || []
})
}
@@ -844,8 +855,7 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
experimentId: scheduled.experimentId,
phase: 'cracking',
startTime: scheduled.crackingStart,
assignedConductors: conductorAssignments[`${markerIdPrefix}-cracking`] || [],
locked: lockedSchedules.has(repId)
assignedConductors: conductorAssignments[`${markerIdPrefix}-cracking`] || []
})
}
})
@@ -856,7 +866,66 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
conductorAvailabilities,
phaseMarkers
}
}, [selectedConductorIds, conductors, conductorColorMap, colorPalette, availabilityEvents, scheduledRepetitions, conductorAssignments, lockedSchedules, calendarStartDate, calendarZoom])
}, [selectedConductorIds, conductors, conductorColorMap, colorPalette, availabilityEvents, scheduledRepetitions, conductorAssignments, calendarStartDate, calendarZoom, selectedRepetitionIds])
// Build repetition metadata mapping for timeline display
const repetitionMetadata = useMemo(() => {
const metadata: Record<string, { phaseName: string; experimentNumber: number; repetitionNumber: number; experimentId: string; isScheduledInDb: boolean }> = {}
Object.values(scheduledRepetitions).forEach(scheduled => {
const repId = scheduled.repetitionId
// Only include metadata for repetitions that are checked (selected)
if (!selectedRepetitionIds.has(repId)) {
return
}
const experiment = Object.values(experimentsByPhase).flat().find(e => e.id === scheduled.experimentId)
const repetition = Object.values(repetitionsByExperiment).flat().find(r => r.id === repId)
const phase = phases.find(p =>
Object.values(experimentsByPhase[p.id] || []).some(e => e.id === scheduled.experimentId)
)
if (experiment && repetition && phase) {
metadata[repId] = {
phaseName: phase.name,
experimentNumber: experiment.experiment_number,
repetitionNumber: repetition.repetition_number,
experimentId: scheduled.experimentId,
// Consider a repetition \"scheduled\" in DB if it has a non-null scheduled_date
isScheduledInDb: Boolean(repetition.scheduled_date)
}
}
})
return metadata
}, [scheduledRepetitions, experimentsByPhase, repetitionsByExperiment, phases, selectedRepetitionIds])
// Scroll to repetition in accordion
const handleScrollToRepetition = useCallback(async (repetitionId: string) => {
// First, expand the phase if it's collapsed
const repetition = Object.values(repetitionsByExperiment).flat().find(r => r.id === repetitionId)
if (repetition) {
const experiment = Object.values(experimentsByPhase).flat().find(e =>
(repetitionsByExperiment[e.id] || []).some(r => r.id === repetitionId)
)
if (experiment) {
const phase = phases.find(p =>
(experimentsByPhase[p.id] || []).some(e => e.id === experiment.id)
)
if (phase && !expandedPhaseIds.has(phase.id)) {
await togglePhaseExpand(phase.id)
// Wait a bit for the accordion to expand
await new Promise(resolve => setTimeout(resolve, 300))
}
}
}
// Then scroll to the element
const element = document.getElementById(`repetition-${repetitionId}`)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}, [repetitionsByExperiment, experimentsByPhase, phases, expandedPhaseIds, togglePhaseExpand])
// Handlers for horizontal calendar
const handleHorizontalMarkerDrag = useCallback((markerId: string, newTime: Date) => {
@@ -878,21 +947,6 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
}))
}, [])
const handleHorizontalMarkerLockToggle = useCallback((markerId: string) => {
// Marker ID format: ${repId}-${phase} where repId is a UUID with hyphens
// Split by '-' and take all but the last segment as repId
const parts = markerId.split('-')
const repId = parts.slice(0, -1).join('-')
setLockedSchedules(prev => {
const next = new Set(prev)
if (next.has(repId)) {
next.delete(repId)
} else {
next.add(repId)
}
return next
})
}, [])
return (
@@ -1027,7 +1081,9 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
phaseMarkers={horizontalCalendarData.phaseMarkers}
onMarkerDrag={handleHorizontalMarkerDrag}
onMarkerAssignConductors={handleHorizontalMarkerAssignConductors}
onMarkerLockToggle={handleHorizontalMarkerLockToggle}
repetitionMetadata={repetitionMetadata}
onScrollToRepetition={handleScrollToRepetition}
onScheduleRepetition={scheduleRepetition}
timeStep={15}
minHour={6}
maxHour={22}
@@ -1196,11 +1252,21 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
const checked = selectedRepetitionIds.has(rep.id)
const hasTimes = repetitionsWithTimes.has(rep.id)
const scheduled = scheduledRepetitions[rep.id]
const isLocked = lockedSchedules.has(rep.id)
const isScheduling = schedulingRepetitions.has(rep.id)
// Check if there are any conductor assignments
const markerIdPrefix = rep.id
const soakingConductors = conductorAssignments[`${markerIdPrefix}-soaking`] || []
const airdryingConductors = conductorAssignments[`${markerIdPrefix}-airdrying`] || []
const crackingConductors = conductorAssignments[`${markerIdPrefix}-cracking`] || []
const hasAssignments = soakingConductors.length > 0 || airdryingConductors.length > 0 || crackingConductors.length > 0
return (
<div key={rep.id} className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded p-3">
<div
key={rep.id}
id={`repetition-${rep.id}`}
className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded p-3"
>
{/* Checkbox row */}
<label className="flex items-center gap-2">
<input
@@ -1212,44 +1278,89 @@ export function ScheduleExperiment({ user, onBack }: { user: User; onBack: () =>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Rep {rep.repetition_number}</span>
</label>
{/* Time points (shown only if has been dropped/moved) */}
{hasTimes && scheduled && (
{/* Time points (shown whenever the repetition has scheduled times) */}
{scheduled && (
<div className="mt-2 ml-6 text-xs space-y-1">
<div className="flex items-center gap-2">
<span>💧</span>
<span>Soaking: {formatTime(scheduled.soakingStart)}</span>
</div>
<div className="flex items-center gap-2">
<span>🌬️</span>
<span>Airdrying: {formatTime(scheduled.airdryingStart)}</span>
</div>
<div className="flex items-center gap-2">
<span>⚡</span>
<span>Cracking: {formatTime(scheduled.crackingStart)}</span>
</div>
{(() => {
const repId = rep.id
const markerIdPrefix = repId
// Get assigned conductors for each phase
const soakingConductors = conductorAssignments[`${markerIdPrefix}-soaking`] || []
const airdryingConductors = conductorAssignments[`${markerIdPrefix}-airdrying`] || []
const crackingConductors = conductorAssignments[`${markerIdPrefix}-cracking`] || []
// Helper to get conductor names
const getConductorNames = (conductorIds: string[]) => {
return conductorIds.map(id => {
const conductor = conductors.find(c => c.id === id)
if (!conductor) return null
return [conductor.first_name, conductor.last_name].filter(Boolean).join(' ') || conductor.email
}).filter(Boolean).join(', ')
}
return (
<>
<div className="flex items-center gap-2 flex-wrap">
<span>💧</span>
<span>Soaking: {formatTime(scheduled.soakingStart)}</span>
{soakingConductors.length > 0 && (
<span className="text-blue-600 dark:text-blue-400">
({getConductorNames(soakingConductors)})
</span>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
<span>🌬️</span>
<span>Airdrying: {formatTime(scheduled.airdryingStart)}</span>
{airdryingConductors.length > 0 && (
<span className="text-green-600 dark:text-green-400">
({getConductorNames(airdryingConductors)})
</span>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
<span>⚡</span>
<span>Cracking: {formatTime(scheduled.crackingStart)}</span>
{crackingConductors.length > 0 && (
<span className="text-orange-600 dark:text-orange-400">
({getConductorNames(crackingConductors)})
</span>
)}
</div>
</>
)
})()}
{/* Lock checkbox and Schedule button */}
{/* Remove Assignments button and Schedule/Unschedule button */}
<div className="flex items-center gap-3 mt-3 pt-2 border-t border-gray-200 dark:border-gray-600">
<label className="flex items-center gap-2">
<input
type="checkbox"
className="h-3 w-3 text-blue-600 border-gray-300 rounded"
checked={isLocked}
onChange={() => {
toggleScheduleLock(rep.id)
}}
/>
<span className="text-xs text-gray-600 dark:text-gray-400">
{isLocked ? '🔒 Locked' : '🔓 Unlocked'}
</span>
</label>
<button
onClick={() => scheduleRepetition(rep.id, exp.id)}
disabled={isScheduling || !isLocked}
className="px-3 py-1 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded text-xs transition-colors"
>
{isScheduling ? 'Scheduling...' : 'Schedule'}
</button>
{hasAssignments && (
<button
onClick={() => removeRepetitionAssignments(rep.id)}
disabled={Boolean(rep.scheduled_date)}
className="px-3 py-1 bg-red-600 hover:bg-red-700 disabled:bg-gray-400 text-white rounded text-xs transition-colors"
title={rep.scheduled_date ? "Unschedule the repetition first before removing assignments" : "Remove all conductor assignments from this repetition"}
>
Remove Assignments
</button>
)}
{rep.scheduled_date ? (
<button
onClick={() => unscheduleRepetition(rep.id, exp.id)}
disabled={isScheduling}
className="px-3 py-1 bg-yellow-600 hover:bg-yellow-700 disabled:bg-gray-400 text-white rounded text-xs transition-colors"
>
{isScheduling ? 'Unscheduling...' : 'Unschedule'}
</button>
) : (
<button
onClick={() => scheduleRepetition(rep.id, exp.id)}
disabled={isScheduling}
className="px-3 py-1 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded text-xs transition-colors"
>
{isScheduling ? 'Scheduling...' : 'Schedule'}
</button>
)}
</div>
</div>
)}

View File

@@ -0,0 +1,57 @@
#!/bin/sh
# Build the project first
echo "Building scheduling-remote..."
npm run build
# Verify the initial build created remoteEntry.js
REMOTE_ENTRY_PATH="dist/assets/remoteEntry.js"
if [ ! -f "$REMOTE_ENTRY_PATH" ]; then
echo "ERROR: Initial build did not create remoteEntry.js!"
exit 1
fi
echo "Initial build complete. remoteEntry.js exists."
# Start build:watch in the background
echo "Starting build:watch in background..."
npm run build:watch &
BUILD_WATCH_PID=$!
# Wait a moment for build:watch to start and potentially rebuild
echo "Waiting for build:watch to stabilize..."
sleep 3
# Verify remoteEntry.js still exists (build:watch might have rebuilt it)
MAX_WAIT=30
WAIT_COUNT=0
while [ ! -f "$REMOTE_ENTRY_PATH" ] && [ $WAIT_COUNT -lt $MAX_WAIT ]; do
sleep 1
WAIT_COUNT=$((WAIT_COUNT + 1))
if [ $((WAIT_COUNT % 5)) -eq 0 ]; then
echo "Waiting for remoteEntry.js after build:watch... (${WAIT_COUNT}s)"
fi
done
if [ ! -f "$REMOTE_ENTRY_PATH" ]; then
echo "ERROR: remoteEntry.js was not available after ${MAX_WAIT} seconds!"
kill $BUILD_WATCH_PID 2>/dev/null || true
exit 1
fi
# Wait a bit more to ensure build:watch has finished any initial rebuild
echo "Ensuring build:watch has completed initial build..."
sleep 2
# Check file size to ensure it's not empty or being written
FILE_SIZE=$(stat -f%z "$REMOTE_ENTRY_PATH" 2>/dev/null || stat -c%s "$REMOTE_ENTRY_PATH" 2>/dev/null || echo "0")
if [ "$FILE_SIZE" -lt 100 ]; then
echo "WARNING: remoteEntry.js seems too small (${FILE_SIZE} bytes), waiting a bit more..."
sleep 2
fi
echo "remoteEntry.js is ready (${FILE_SIZE} bytes). Starting http-server..."
# Start http-server and give it time to fully initialize
# Use a simple approach: start server and wait a moment for it to be ready
exec npx http-server dist -p 3003 --cors -c-1

View File

@@ -60,57 +60,69 @@ echo "4. Rebuilding and starting all services in detached mode..."
docker compose up --build -d
echo ""
echo "5. Waiting for Supabase database to be ready..."
# Wait for database to be healthy
MAX_WAIT=60
WAIT_COUNT=0
while [ $WAIT_COUNT -lt $MAX_WAIT ]; do
if docker compose ps supabase-db | grep -q "healthy"; then
echo " ✓ Supabase database is healthy"
break
fi
echo " Waiting for database... ($WAIT_COUNT/$MAX_WAIT seconds)"
sleep 2
WAIT_COUNT=$((WAIT_COUNT + 2))
done
echo "5. Waiting for Supabase database to be ready (if configured)..."
if [ $WAIT_COUNT -ge $MAX_WAIT ]; then
echo " ⚠ Warning: Database may not be fully ready"
# Only wait for Supabase if the supabase-db service exists in docker-compose.yml
if docker compose config --services 2>/dev/null | grep -q "^supabase-db$"; then
# Wait for database to be healthy
MAX_WAIT=60
WAIT_COUNT=0
while [ $WAIT_COUNT -lt $MAX_WAIT ]; do
if docker compose ps supabase-db | grep -q "healthy"; then
echo " ✓ Supabase database is healthy"
break
fi
echo " Waiting for database... ($WAIT_COUNT/$MAX_WAIT seconds)"
sleep 2
WAIT_COUNT=$((WAIT_COUNT + 2))
done
if [ $WAIT_COUNT -ge $MAX_WAIT ]; then
echo " ⚠ Warning: Database may not be fully ready"
fi
else
echo " - Supabase services are currently disabled in docker-compose.yml; skipping DB wait step."
fi
echo ""
echo "6. Waiting for Supabase migrations to complete..."
# Wait for migration container to complete (it has restart: "no", so it should exit when done)
MAX_WAIT=120
WAIT_COUNT=0
MIGRATE_CONTAINER="usda-vision-supabase-migrate"
echo "6. Waiting for Supabase migrations to complete (if configured)..."
while [ $WAIT_COUNT -lt $MAX_WAIT ]; do
# Check if container exists and its status
if docker ps -a --format "{{.Names}}\t{{.Status}}" | grep -q "^${MIGRATE_CONTAINER}"; then
CONTAINER_STATUS=$(docker ps -a --format "{{.Names}}\t{{.Status}}" | grep "^${MIGRATE_CONTAINER}" | awk '{print $2}')
if echo "$CONTAINER_STATUS" | grep -q "Exited"; then
EXIT_CODE=$(docker inspect "$MIGRATE_CONTAINER" --format='{{.State.ExitCode}}' 2>/dev/null || echo "1")
if [ "$EXIT_CODE" = "0" ]; then
echo " ✓ Supabase migrations completed successfully"
break
else
echo " ⚠ Warning: Migrations may have failed (exit code: $EXIT_CODE)"
echo " Check logs with: docker compose logs supabase-migrate"
break
# Only wait for the migration container if the supabase-migrate service exists
if docker compose config --services 2>/dev/null | grep -q "^supabase-migrate$"; then
# Wait for migration container to complete (it has restart: "no", so it should exit when done)
MAX_WAIT=120
WAIT_COUNT=0
MIGRATE_CONTAINER="usda-vision-supabase-migrate"
while [ $WAIT_COUNT -lt $MAX_WAIT ]; do
# Check if container exists and its status
if docker ps -a --format "{{.Names}}\t{{.Status}}" | grep -q "^${MIGRATE_CONTAINER}"; then
CONTAINER_STATUS=$(docker ps -a --format "{{.Names}}\t{{.Status}}" | grep "^${MIGRATE_CONTAINER}" | awk '{print $2}')
if echo "$CONTAINER_STATUS" | grep -q "Exited"; then
EXIT_CODE=$(docker inspect "$MIGRATE_CONTAINER" --format='{{.State.ExitCode}}' 2>/dev/null || echo "1")
if [ "$EXIT_CODE" = "0" ]; then
echo " ✓ Supabase migrations completed successfully"
break
else
echo " ⚠ Warning: Migrations may have failed (exit code: $EXIT_CODE)"
echo " Check logs with: docker compose logs supabase-migrate"
break
fi
fi
fi
fi
echo " Waiting for migrations... ($WAIT_COUNT/$MAX_WAIT seconds)"
sleep 2
WAIT_COUNT=$((WAIT_COUNT + 2))
done
echo " Waiting for migrations... ($WAIT_COUNT/$MAX_WAIT seconds)"
sleep 2
WAIT_COUNT=$((WAIT_COUNT + 2))
done
if [ $WAIT_COUNT -ge $MAX_WAIT ]; then
echo " ⚠ Warning: Migration timeout - check logs with: docker compose logs supabase-migrate"
echo " Note: Migrations may still be running or the container may not have started yet"
if [ $WAIT_COUNT -ge $MAX_WAIT ]; then
echo " ⚠ Warning: Migration timeout - check logs with: docker compose logs supabase-migrate"
echo " Note: Migrations may still be running or the container may not have started yet"
fi
else
echo " - Supabase migration service is currently disabled in docker-compose.yml; skipping migration wait step."
fi
echo ""