2026-07-12 14:19:58 -04:00
|
|
|
import { useRef, useEffect, useState } from 'react'
|
|
|
|
|
|
2026-06-17 16:02:04 -04:00
|
|
|
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',
|
2026-06-25 11:31:20 -04:00
|
|
|
rerun: 'bg-red-800/60 text-red-200 border-red-600',
|
2026-06-17 16:02:04 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 14:19:58 -04:00
|
|
|
export const DEVICE_ACCENT_CLASSES = [
|
2026-06-26 12:02:15 -04:00
|
|
|
'bg-sky-400',
|
|
|
|
|
'bg-emerald-400',
|
|
|
|
|
'bg-amber-400',
|
|
|
|
|
'bg-rose-400',
|
|
|
|
|
'bg-cyan-400',
|
|
|
|
|
'bg-lime-400',
|
|
|
|
|
'bg-orange-400',
|
|
|
|
|
'bg-fuchsia-400',
|
|
|
|
|
]
|
|
|
|
|
|
2026-07-12 14:19:58 -04:00
|
|
|
export function getDeviceAccentClass(device) {
|
2026-06-26 12:02:15 -04:00
|
|
|
if (!device) return 'bg-gray-500'
|
|
|
|
|
|
|
|
|
|
let hash = 0
|
|
|
|
|
for (let i = 0; i < device.length; i += 1) {
|
|
|
|
|
hash = (hash * 31 + device.charCodeAt(i)) >>> 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return DEVICE_ACCENT_CLASSES[hash % DEVICE_ACCENT_CLASSES.length]
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 12:07:43 -04:00
|
|
|
function formatConfig(config) {
|
|
|
|
|
if (!config || typeof config !== 'object') return '—'
|
|
|
|
|
const entries = Object.entries(config)
|
|
|
|
|
if (entries.length === 0) return '—'
|
|
|
|
|
const formattedEntries = entries
|
|
|
|
|
.map(([band, data]) => {
|
|
|
|
|
if (!data || typeof data !== 'object') return null
|
|
|
|
|
const testpoint = data.test_point || data['Test Point']
|
|
|
|
|
const sta = data.sta || data.STA
|
|
|
|
|
// Only display if both testpoint and sta are present
|
|
|
|
|
if (!testpoint || !sta) return null
|
|
|
|
|
return `${band}: ${testpoint}→${sta}`
|
|
|
|
|
})
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
|
|
|
|
return formattedEntries.length > 0 ? formattedEntries.join('; ') : '—'
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 16:02:04 -04:00
|
|
|
export default function TestCard({ test }) {
|
|
|
|
|
const style = STATUS_STYLES[test.status] ?? STATUS_STYLES.pending
|
2026-06-26 12:02:15 -04:00
|
|
|
const deviceAccentClass = getDeviceAccentClass(test.device)
|
2026-07-12 14:19:58 -04:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}, [])
|
2026-06-17 16:02:04 -04:00
|
|
|
|
|
|
|
|
return (
|
2026-07-12 14:19:58 -04:00
|
|
|
<div ref={cardRef} className="relative">
|
2026-06-17 16:02:04 -04:00
|
|
|
<div
|
2026-06-26 12:02:15 -04:00
|
|
|
className={`relative px-1.5 py-0.5 pl-3 rounded border text-xs font-mono truncate cursor-default select-none ${style}`}
|
2026-06-17 16:02:04 -04:00
|
|
|
style={{ maxWidth: '100%' }}
|
|
|
|
|
>
|
2026-06-26 12:02:15 -04:00
|
|
|
<span className={`absolute inset-y-0 left-0 w-1 rounded-l ${deviceAccentClass}`} aria-hidden="true" />
|
2026-06-17 16:02:04 -04:00
|
|
|
{test.test_id}
|
|
|
|
|
</div>
|
2026-07-12 14:19:58 -04:00
|
|
|
{/* 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,
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-06-17 16:02:04 -04:00
|
|
|
<p><span className="text-gray-400">Type:</span> {test.test_type ?? '—'}</p>
|
|
|
|
|
<p><span className="text-gray-400">Rotation:</span> {test.rotation ?? '—'}</p>
|
2026-06-26 12:02:15 -04:00
|
|
|
<p className="flex items-center gap-1.5">
|
|
|
|
|
<span className="text-gray-400">Device:</span>
|
|
|
|
|
<span className={`inline-block w-2 h-2 rounded-full ${deviceAccentClass}`} aria-hidden="true" />
|
|
|
|
|
<span>{test.device ?? '—'}</span>
|
|
|
|
|
</p>
|
2026-06-23 12:07:43 -04:00
|
|
|
<p className="mt-1 pt-1 border-t border-gray-600"><span className="text-gray-400">Config:</span> {formatConfig(test.config)}</p>
|
2026-06-17 16:02:04 -04:00
|
|
|
</div>
|
2026-07-12 14:19:58 -04:00
|
|
|
)}
|
2026-06-17 16:02:04 -04:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|