knapsack scheduling algorithm

This commit is contained in:
2026-07-12 14:19:58 -04:00
parent 031da48ddd
commit 9e100d60d0
56 changed files with 1936 additions and 1286 deletions
+53 -12
View File
@@ -1,9 +1,10 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import Header from './components/Header'
import FailedBanner from './components/FailedBanner'
import Calendar from './components/Calendar'
import RightPanel from './components/RightPanel'
import SettingsModal from './components/SettingsModal'
import Header from './components/Header'
import FailedBanner from './components/FailedBanner'
import Calendar from './components/Calendar'
import RightPanel from './components/RightPanel'
import SettingsModal from './components/SettingsModal'
import TestWindowDetailsPanel from './components/TestWindowDetailsPanel'
import { api, groupScheduleItems } from './api'
// ---------------------------------------------------------------------------
@@ -123,19 +124,37 @@ export default function App() {
const [topPriority, setTopPriority] = useState('')
const [lowestPriority, setLowestPriority] = useState('')
const [startDateOverride, setStartDateOverride] = useState('')
const [failedTests, setFailedTests] = useState([])
const [scheduleData, setScheduleData] = useState({})
const [completionDate, setCompletionDate] = useState(null)
const [weekStart, setWeekStart] = useState(() => getMondayOfWeek(new Date()))
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [failedTests, setFailedTests] = useState([])
const [scheduleData, setScheduleData] = useState({})
const [scheduleWindows, setScheduleWindows] = useState([])
const [selectedWindowId, setSelectedWindowId] = useState(null)
const [completionDate, setCompletionDate] = useState(null)
const [weekStart, setWeekStart] = useState(() => getMondayOfWeek(new Date()))
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const tonightConfigRows = useMemo(() => toTonightConfigRows(scheduleData), [scheduleData])
const windowLookup = useMemo(() => {
const lookup = new Map()
for (const windowDetails of scheduleWindows) {
for (const segment of windowDetails.segments ?? []) {
lookup.set(`${segment.date}::shift${segment.shift_index}`, windowDetails)
}
}
return lookup
}, [scheduleWindows])
const selectedWindow = useMemo(
() => scheduleWindows.find((windowDetails) => windowDetails.window_id === selectedWindowId) ?? null,
[scheduleWindows, selectedWindowId],
)
// Fetch schedule for the given weekStart (Monday)
const fetchSchedule = useCallback(async (start) => {
try {
const data = await api.getScheduleWeek(toKey(start))
setScheduleData(groupScheduleItems(data.items))
setScheduleWindows(data.windows ?? [])
} catch (e) {
console.error('Failed to fetch schedule:', e)
}
@@ -268,6 +287,20 @@ export default function App() {
setFailedTests([])
}
useEffect(() => {
if (selectedWindowId && !selectedWindow) {
setSelectedWindowId(null)
}
}, [selectedWindow, selectedWindowId])
function handleWindowSelect(windowDetails) {
setSelectedWindowId(windowDetails?.window_id ?? null)
}
function handleCloseWindowDetails() {
setSelectedWindowId(null)
}
// -------------------------------------------------------------------------
return (
@@ -286,13 +319,15 @@ export default function App() {
onDecision={handleRerunDecision}
/>
<main className="flex flex-1 gap-4 p-4 overflow-hidden">
<main className="relative flex flex-1 gap-4 p-4 overflow-hidden">
<div className="flex-1 min-w-0 flex flex-col">
<Calendar
scheduleData={scheduleData}
daytimeDateKey={daytimeEnabled ? toKey(new Date()) : null}
weekStart={weekStart}
onWeekChange={setWeekStart}
windowLookup={windowLookup}
onWindowSelect={handleWindowSelect}
/>
</div>
@@ -310,6 +345,12 @@ export default function App() {
loading={loading}
tonightConfigRows={tonightConfigRows}
/>
<TestWindowDetailsPanel
isOpen={Boolean(selectedWindow)}
windowDetails={selectedWindow}
onClose={handleCloseWindowDetails}
/>
</main>
<SettingsModal
Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

+30 -2
View File
@@ -1,4 +1,5 @@
import DayColumn from './DayColumn'
import { getDeviceAccentClass } from './TestCard'
function addDays(date, n) {
const d = new Date(date)
@@ -47,7 +48,14 @@ function getNextTestWindow() {
}
// scheduleData: { "YYYY-MM-DD": { shift1: [...], shift2: [...], shift3: [...] }, ... }
export default function Calendar({ scheduleData = {}, daytimeDateKey = null, weekStart, onWeekChange }) {
export default function Calendar({
scheduleData = {},
daytimeDateKey = null,
weekStart,
onWeekChange,
windowLookup = new Map(),
onWindowSelect,
}) {
const today = new Date()
today.setHours(0, 0, 0, 0)
@@ -58,6 +66,18 @@ export default function Calendar({ scheduleData = {}, daytimeDateKey = null, wee
// Reorder columns: Sun Mon Tue Wed Thu Fri Sat
// weekStart is Monday, so days[0]=Mon ... days[6]=Sun → put Sunday first
const orderedDays = [ ...days.slice(0, 7)]
const deviceLegendItems = Array.from(
new Set(
Object.values(scheduleData)
.flatMap((day) => [
...(day?.shift1 ?? []),
...(day?.shift2 ?? []),
...(day?.shift3 ?? []),
])
.map((test) => test?.device)
.filter(Boolean),
),
).sort((a, b) => String(a).localeCompare(String(b)))
return (
<div className="flex flex-col gap-3 h-full">
@@ -112,16 +132,24 @@ export default function Calendar({ scheduleData = {}, daytimeDateKey = null, wee
activeShifts={activeShifts}
shifts={shifts}
showShift2={isWeekend || (daytimeDateKey !== null && key === daytimeDateKey)}
windowLookup={windowLookup}
onWindowSelect={onWindowSelect}
/>
)
})}
</div>
{/* Legend */}
<div className="flex gap-4 text-xs text-gray-400">
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs text-gray-400">
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-gray-500 inline-block" />Pending</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-green-700 inline-block" />Completed</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-red-700 inline-block" />Rerun Required</span>
{deviceLegendItems.map((device) => (
<span key={device} className="flex items-center gap-1.5">
<span className={`w-2.5 h-2.5 rounded-sm inline-block ${getDeviceAccentClass(device)}`} />
{device}
</span>
))}
</div>
</div>
)
+39 -8
View File
@@ -2,14 +2,24 @@ import ShiftSlot from './ShiftSlot'
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
export default function DayColumn({ date, isToday, activeShifts = new Set(), shifts = {}, showShift2 = false }) {
export default function DayColumn({
date,
isToday,
activeShifts = new Set(),
shifts = {},
showShift2 = false,
windowLookup = new Map(),
onWindowSelect,
}) {
const dayName = DAY_NAMES[date.getDay()]
const dayNum = date.getDate()
const isWeekend = date.getDay() === 0 || date.getDay() === 6
const hasActive = activeShifts.size > 0
const dateKey = date.toISOString().slice(0, 10)
// Shift 2 is visible if: weekend, holiday (showShift2), or daytime testing on
const shift2Visible = isWeekend || showShift2
function getWindowForShift(shiftIndex) {
return windowLookup.get(`${dateKey}::shift${shiftIndex}`) ?? null
}
const shift2Visible = showShift2 || shifts.shift2.length > 0 || Boolean(getWindowForShift(2))
return (
<div className="flex flex-col min-w-0 flex-1 rounded-lg border border-gray-700 bg-gray-800/40">
@@ -29,9 +39,30 @@ export default function DayColumn({ date, isToday, activeShifts = new Set(), shi
{/* Shifts */}
<div className="flex flex-col flex-1 px-0.5 py-1">
<ShiftSlot label="12AM9AM" tests={shifts.shift1} visible={true} active={activeShifts.has(1)} />
<ShiftSlot label="9AM5PM" tests={shifts.shift2} visible={true} active={activeShifts.has(2)} />
<ShiftSlot label="5PM12AM" tests={shifts.shift3} visible={true} active={activeShifts.has(3)} />
<ShiftSlot
label="12AM9AM"
tests={shifts.shift1}
visible={true}
active={activeShifts.has(1)}
windowDetails={getWindowForShift(1)}
onWindowSelect={onWindowSelect}
/>
<ShiftSlot
label="9AM5PM"
tests={shifts.shift2}
visible={true}
active={activeShifts.has(2)}
windowDetails={getWindowForShift(2)}
onWindowSelect={onWindowSelect}
/>
<ShiftSlot
label="5PM12AM"
tests={shifts.shift3}
visible={true}
active={activeShifts.has(3)}
windowDetails={getWindowForShift(3)}
onWindowSelect={onWindowSelect}
/>
</div>
</div>
)
+1 -1
View File
@@ -2,7 +2,7 @@ export default function Header({ onOpenSettings }) {
return (
<header className="flex items-center justify-between px-6 py-3 bg-gray-900 border-b border-gray-700 shrink-0">
<h1 className="text-white font-semibold text-lg tracking-wide">
CGW453 Scheduler
NJTH Scheduler
</h1>
<button
onClick={onOpenSettings}
+3 -3
View File
@@ -196,12 +196,12 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
Test Exclusion
</p>
<Field label="Excluded Test IDs" hint="(comma-separated)">
<Field label="Exclusions" hint="(comma-separated)">
<input
type="text"
className={INPUT_CLS}
placeholder="P2PRXAX001, COERXBE002…"
value={form.testExclusion ?? ''}
placeholder="SP, BW320,..."
value={form.testExclusion?? ''}
onChange={(e) => set('testExclusion', e.target.value)}
/>
</Field>
+36 -9
View File
@@ -1,17 +1,44 @@
import TestCard from './TestCard'
export default function ShiftSlot({ label, tests = [], visible = true, active = false }) {
export default function ShiftSlot({
label,
tests = [],
visible = true,
active = false,
windowDetails = null,
onWindowSelect,
}) {
if (!visible) return null
const clickable = Boolean(windowDetails && onWindowSelect)
return (
<div className={`border-t pt-1 pb-1.5 ${
active ? 'border-blue-500/50 bg-blue-950/20 rounded' : 'border-gray-700/60'
}`}>
<p className={`text-[10px] font-semibold uppercase tracking-wider mb-1 px-1 ${
active ? 'text-blue-400' : 'text-gray-500'
}`}>
{label}
</p>
<div
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={clickable ? () => onWindowSelect(windowDetails) : undefined}
onKeyDown={clickable ? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onWindowSelect(windowDetails)
}
} : undefined}
className={`border-t pt-1 pb-1.5 transition-colors ${
active ? 'border-blue-500/50 bg-blue-950/20 rounded' : 'border-gray-700/60'
} ${clickable ? 'cursor-pointer hover:bg-gray-700/25 focus:outline-none focus:ring-1 focus:ring-cyan-400/70' : ''}`}
>
<div className="flex items-center justify-between gap-2 px-1 mb-1">
<p className={`text-[10px] font-semibold uppercase tracking-wider ${
active ? 'text-blue-400' : 'text-gray-500'
}`}>
{label}
</p>
{clickable && (
<span className="text-[9px] font-semibold uppercase tracking-[0.2em] text-cyan-400/80">
</span>
)}
</div>
<div className="flex flex-col gap-0.5 px-1 min-h-4">
{tests.length === 0 ? (
<span className="text-[10px] text-gray-600 italic"></span>
+51 -7
View File
@@ -1,10 +1,12 @@
import { useRef, useEffect, useState } from 'react'
const STATUS_STYLES = {
pending: 'bg-gray-600/60 text-gray-200 border-gray-500',
completed: 'bg-green-800/60 text-green-200 border-green-600',
rerun: 'bg-red-800/60 text-red-200 border-red-600',
}
const DEVICE_ACCENT_CLASSES = [
export const DEVICE_ACCENT_CLASSES = [
'bg-sky-400',
'bg-emerald-400',
'bg-amber-400',
@@ -15,7 +17,7 @@ const DEVICE_ACCENT_CLASSES = [
'bg-fuchsia-400',
]
function getDeviceAccentClass(device) {
export function getDeviceAccentClass(device) {
if (!device) return 'bg-gray-500'
let hash = 0
@@ -47,9 +49,46 @@ function formatConfig(config) {
export default function TestCard({ test }) {
const style = STATUS_STYLES[test.status] ?? STATUS_STYLES.pending
const deviceAccentClass = getDeviceAccentClass(test.device)
const cardRef = useRef(null)
const [tooltipPos, setTooltipPos] = useState({ top: '0px', left: '0px' })
const [isHovered, setIsHovered] = useState(false)
useEffect(() => {
const cardElement = cardRef.current
if (!cardElement) return
const handleMouseMove = (e) => {
// Position tooltip near the cursor, with 10px offset
const top = e.clientY + 10
const left = e.clientX + 10
setTooltipPos({
top: `${top}px`,
left: `${left}px`,
})
}
const handleMouseEnter = () => {
setIsHovered(true)
}
const handleMouseLeave = () => {
setIsHovered(false)
}
cardElement.addEventListener('mouseenter', handleMouseEnter)
cardElement.addEventListener('mouseleave', handleMouseLeave)
cardElement.addEventListener('mousemove', handleMouseMove)
return () => {
cardElement.removeEventListener('mouseenter', handleMouseEnter)
cardElement.removeEventListener('mouseleave', handleMouseLeave)
cardElement.removeEventListener('mousemove', handleMouseMove)
}
}, [])
return (
<div className="relative group">
<div ref={cardRef} className="relative">
<div
className={`relative px-1.5 py-0.5 pl-3 rounded border text-xs font-mono truncate cursor-default select-none ${style}`}
style={{ maxWidth: '100%' }}
@@ -57,9 +96,14 @@ export default function TestCard({ test }) {
<span className={`absolute inset-y-0 left-0 w-1 rounded-l ${deviceAccentClass}`} aria-hidden="true" />
{test.test_id}
</div>
{/* Hover tooltip */}
<div className="absolute z-50 bottom-full left-0 mb-1 hidden group-hover:block min-w-max">
<div className="bg-gray-800 border border-gray-600 rounded-md px-2.5 py-1.5 text-xs text-gray-200 shadow-lg max-w-md">
{/* Hover tooltip — follows cursor */}
{isHovered && (
<div className="fixed z-50 bg-gray-800 border border-gray-600 rounded-md px-2.5 py-1.5 text-xs text-gray-200 shadow-lg max-w-md pointer-events-none"
style={{
top: tooltipPos.top,
left: tooltipPos.left,
}}
>
<p><span className="text-gray-400">Type:</span> {test.test_type ?? '—'}</p>
<p><span className="text-gray-400">Rotation:</span> {test.rotation ?? '—'}</p>
<p className="flex items-center gap-1.5">
@@ -69,7 +113,7 @@ export default function TestCard({ test }) {
</p>
<p className="mt-1 pt-1 border-t border-gray-600"><span className="text-gray-400">Config:</span> {formatConfig(test.config)}</p>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,295 @@
import { useEffect, useMemo, useState } from 'react'
const STATUS_STYLES = {
pending: 'bg-gray-700 text-gray-200 border-gray-600',
completed: 'bg-green-900/60 text-green-200 border-green-700',
rerun: 'bg-red-900/60 text-red-200 border-red-700',
}
function formatWindowHeader(windowDetails) {
if (!windowDetails?.start_at || !windowDetails?.end_at) return 'Window details'
const start = new Date(windowDetails.start_at)
const end = new Date(windowDetails.end_at)
const startLabel = start.toLocaleString('en-US', {
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
}).replace(':00', '').replace(' AM', 'am').replace(' PM', 'pm')
const endLabel = end.toLocaleString('en-US', {
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
}).replace(':00', '').replace(' AM', 'am').replace(' PM', 'pm')
return `${startLabel} - ${endLabel}`
}
function formatHours(minutes) {
if (!Number.isFinite(minutes) || minutes <= 0) return '0 hrs'
const hours = minutes / 60
const rounded = Number.isInteger(hours) ? String(hours) : hours.toFixed(1)
return `${rounded} hrs`
}
function formatShiftLabel(test) {
const shiftLabels = {
1: '12AM9AM',
2: '9AM5PM',
3: '5PM12AM',
}
const date = test?.scheduled_date
const label = shiftLabels[test?.shift_index] ?? 'Unknown shift'
return date ? `${date} ${label}` : label
}
function getWindowTestConfig(windowDetails) {
if (Array.isArray(windowDetails?.bundle_test_configs)) {
return windowDetails.bundle_test_configs
.map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter(Boolean)
}
const tests = windowDetails?.tests ?? []
const allKeys = new Set()
for (const test of tests) {
if (Array.isArray(test?.bundle_test_configs)) {
for (const key of test.bundle_test_configs) {
if (typeof key === 'string' && key.trim()) {
allKeys.add(key.trim())
}
}
continue
}
if (typeof test?.test_config === 'string' && test.test_config.trim()) {
allKeys.add(test.test_config.trim())
}
}
return Array.from(allKeys).sort()
}
function formatConfigEntries(config) {
if (!config || typeof config !== 'object') return []
return Object.entries(config)
.map(([band, data]) => {
if (!data || typeof data !== 'object') return null
const testPoint = data.test_point || data['Test Point'] || '—'
const sta = data.sta || data.STA || '—'
return { band, testPoint, sta }
})
.filter(Boolean)
}
function TestRow({ test }) {
const [expanded, setExpanded] = useState(false)
const statusStyle = STATUS_STYLES[test.status] ?? STATUS_STYLES.pending
const configEntries = useMemo(() => formatConfigEntries(test.config), [test.config])
return (
<div className="rounded-xl border border-gray-700 bg-gray-900/70 overflow-hidden">
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="w-full px-4 py-3 text-left hover:bg-gray-800/80 transition-colors"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-white">{test.test_id}</p>
<p className="mt-1 text-xs text-gray-400">{test.device ?? '—'} · {formatShiftLabel(test)}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`px-2 py-1 rounded-full border text-[11px] font-semibold uppercase tracking-wide ${statusStyle}`}>
{test.status ?? 'pending'}
</span>
<span className="text-xs text-gray-400">{formatHours(test.estimated_minutes)}</span>
</div>
</div>
</button>
{expanded && (
<div className="px-4 pb-4 pt-1 border-t border-gray-800 bg-gray-950/60">
<div className="grid grid-cols-2 gap-3 text-xs text-gray-300">
<div>
<p className="text-gray-500 uppercase tracking-wide text-[11px] mb-1">Type</p>
<p>{test.test_type ?? '—'}</p>
</div>
<div>
<p className="text-gray-500 uppercase tracking-wide text-[11px] mb-1">Rotation</p>
<p>{test.rotation ?? '—'}</p>
</div>
<div>
<p className="text-gray-500 uppercase tracking-wide text-[11px] mb-1">Priority</p>
<p>{test.priority ?? '—'}</p>
</div>
<div>
<p className="text-gray-500 uppercase tracking-wide text-[11px] mb-1">Sequence</p>
<p>{test.sequence_in_shift ?? '—'}</p>
</div>
</div>
<div className="mt-4">
<p className="text-gray-500 uppercase tracking-wide text-[11px] mb-2">Config</p>
{configEntries.length === 0 ? (
<p className="text-xs text-gray-500">No config details available.</p>
) : (
<div className="rounded-lg border border-gray-800 overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-gray-900/90">
<tr>
<th className="text-left px-3 py-2 font-semibold text-gray-400 border-b border-gray-800">Band</th>
<th className="text-left px-3 py-2 font-semibold text-gray-400 border-b border-gray-800">Testpoint</th>
<th className="text-left px-3 py-2 font-semibold text-gray-400 border-b border-gray-800">STA</th>
</tr>
</thead>
<tbody>
{configEntries.map((entry) => (
<tr key={`${test.test_id}-${entry.band}`} className="odd:bg-gray-950 even:bg-gray-900/60">
<td className="px-3 py-2 text-gray-200 border-b border-gray-800">{entry.band}</td>
<td className="px-3 py-2 text-gray-300 border-b border-gray-800">{entry.testPoint}</td>
<td className="px-3 py-2 text-gray-300 border-b border-gray-800">{entry.sta}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
</div>
)
}
export default function TestWindowDetailsPanel({ isOpen, windowDetails, onClose }) {
const windowTestConfig = useMemo(() => getWindowTestConfig(windowDetails), [windowDetails])
useEffect(() => {
if (!isOpen) return undefined
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
onClose()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, onClose])
return (
<div
className={`absolute inset-0 z-40 transition-opacity duration-300 ${
isOpen ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'
}`}
aria-hidden={!isOpen}
>
<button
type="button"
onClick={onClose}
className="absolute inset-0 bg-gray-950/45 backdrop-blur-[1px]"
aria-label="Close test window details"
/>
<aside
className={`absolute inset-y-0 right-0 w-full max-w-[30rem] border-l border-gray-700 bg-gray-950/96 shadow-2xl transition-transform duration-300 ${
isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
<div className="flex h-full flex-col">
<div className="flex items-start justify-between gap-4 border-b border-gray-800 px-5 py-5">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-cyan-400">Test Window</p>
<h2 className="mt-2 text-xl font-semibold text-white">
{windowDetails ? formatWindowHeader(windowDetails) : 'Window details'}
</h2>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-gray-700 p-2 text-gray-400 hover:border-gray-500 hover:text-white transition-colors"
aria-label="Close panel"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{windowDetails && (
<div className="flex-1 overflow-y-auto px-5 py-5">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-2xl border border-gray-800 bg-gray-900/80 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">Available Run Time</p>
<p className="mt-2 text-2xl font-semibold text-white">{formatHours(windowDetails.available_runtime_minutes)}</p>
</div>
<div className="rounded-2xl border border-gray-800 bg-gray-900/80 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">Estimated Run Time</p>
<p className="mt-2 text-2xl font-semibold text-white">{formatHours(windowDetails.estimated_runtime_minutes)}</p>
</div>
</div>
<div className="mt-5 rounded-2xl border border-gray-800 bg-gray-900/80 p-4">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-white">Config</p>
<span className="rounded-full border border-cyan-700/60 bg-cyan-900/30 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-cyan-200">
{windowTestConfig.length > 0 ? windowTestConfig.join(', ') : 'N/A'}
</span>
</div>
{windowDetails.configRows?.length ? (
<div className="mt-3 overflow-hidden rounded-xl border border-gray-800">
<table className="w-full text-xs">
<thead className="bg-gray-950/90">
<tr>
<th className="px-3 py-2 text-left font-semibold text-gray-400 border-b border-gray-800">STA</th>
<th className="px-3 py-2 text-left font-semibold text-gray-400 border-b border-gray-800">Testpoint</th>
</tr>
</thead>
<tbody>
{windowDetails.configRows.map((row, index) => (
<tr key={`${row.sta}-${index}`} className="odd:bg-gray-950 even:bg-gray-900/60">
<td className="px-3 py-2 border-b border-gray-800 font-semibold text-gray-200">{row.sta}</td>
<td className="px-3 py-2 border-b border-gray-800 text-gray-300">{row.testPoints.join(', ')}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="mt-3 text-xs text-gray-500">No STA placement mapping available for this window.</p>
)}
</div>
<div className="mt-5">
<div className="flex items-center justify-between gap-3 mb-3">
<p className="text-sm font-semibold text-white">Tests Scheduled In This Window</p>
<span className="text-xs text-gray-500">{windowDetails.tests?.length ?? 0} tests</span>
</div>
{windowDetails.tests?.length ? (
<div className="flex flex-col gap-3">
{windowDetails.tests.map((test) => (
<TestRow key={`${test.test_id}-${test.device}-${test.scheduled_date}-${test.shift_index}`} test={test} />
))}
</div>
) : (
<div className="rounded-2xl border border-dashed border-gray-800 bg-gray-900/50 px-4 py-6 text-sm text-gray-500">
No tests are currently scheduled in this window.
</div>
)}
</div>
</div>
)}
</div>
</aside>
</div>
)
}