Initial Commit
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
export default function CompletionBar({ value = 0, label }) {
|
||||
const pct = parseFloat((value * 100).toFixed(1))
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && <p className="text-slate-400 text-xs mb-1">{label}</p>}
|
||||
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-right text-xs text-slate-400 mt-0.5">{pct}%</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import DirectoryBrowser from './DirectoryBrowser'
|
||||
import { useConfig, useSaveConfig } from '../hooks/useConfig'
|
||||
|
||||
function fmtSeconds(s) {
|
||||
if (s == null) return ''
|
||||
const m = Math.round(parseFloat(s) / 60)
|
||||
return String(m)
|
||||
}
|
||||
|
||||
export default function ConfigModal({ onClose }) {
|
||||
const { data: config, isLoading } = useConfig()
|
||||
const { mutate: save, isPending } = useSaveConfig()
|
||||
|
||||
const [form, setForm] = useState({})
|
||||
const [browser, setBrowser] = useState(null) // 'target_dir' | 'results_dir' | null
|
||||
const [scanResult, setScanResult] = useState(null) // { testCount, completedCount } | null
|
||||
const [saveError, setSaveError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setForm({
|
||||
target_dir: config.target_dir ?? '',
|
||||
results_dir: config.results_dir ?? '',
|
||||
avg_time_coe: config.avg_time_coe ? fmtSeconds(config.avg_time_coe) : '',
|
||||
avg_time_p2p: config.avg_time_p2p ? fmtSeconds(config.avg_time_p2p) : '',
|
||||
avg_time_p3p: config.avg_time_p3p ? fmtSeconds(config.avg_time_p3p) : '',
|
||||
})
|
||||
}
|
||||
}, [config])
|
||||
|
||||
function handleSave() {
|
||||
setSaveError(null)
|
||||
setScanResult(null)
|
||||
const payload = {
|
||||
target_dir: form.target_dir || null,
|
||||
results_dir: form.results_dir || null,
|
||||
// Convert minutes → seconds; empty/null clears the override
|
||||
avg_time_coe: form.avg_time_coe ? String(parseFloat(form.avg_time_coe) * 60) : null,
|
||||
avg_time_p2p: form.avg_time_p2p ? String(parseFloat(form.avg_time_p2p) * 60) : null,
|
||||
avg_time_p3p: form.avg_time_p3p ? String(parseFloat(form.avg_time_p3p) * 60) : null,
|
||||
}
|
||||
save(payload, {
|
||||
onSuccess: (data) => {
|
||||
if (data?.testCount !== null && data?.testCount !== undefined) {
|
||||
setScanResult({ testCount: data.testCount, completedCount: data.completedCount })
|
||||
// Auto-close after showing result only if tests were found
|
||||
if (data.testCount > 0) setTimeout(onClose, 1500)
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
setSaveError(err?.message ?? 'Failed to save settings')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const AVG_FIELDS = [
|
||||
{ key: 'avg_time_coe', label: 'COE avg time (min)' },
|
||||
{ key: 'avg_time_p2p', label: 'P2P avg time (min)' },
|
||||
{ key: 'avg_time_p3p', label: 'P3P avg time (min)' },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-lg mx-4 shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-800">
|
||||
<h2 className="text-slate-100 font-semibold">Settings</h2>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-200">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 space-y-6">
|
||||
{/* Error banner */}
|
||||
{saveError && (
|
||||
<div className="bg-red-950/50 border border-red-700 rounded-lg px-4 py-3 text-red-300 text-sm">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scan result banner */}
|
||||
{scanResult !== null && (
|
||||
<div className={`rounded-lg px-4 py-3 text-sm border ${
|
||||
scanResult.testCount === 0
|
||||
? 'bg-amber-950/50 border-amber-700 text-amber-300'
|
||||
: 'bg-emerald-950/50 border-emerald-700 text-emerald-300'
|
||||
}`}>
|
||||
{scanResult.testCount === 0
|
||||
? 'No tests found — check that the target directory contains TC_WIFI_*.ini files in subdirectories.'
|
||||
: `Found ${scanResult.testCount} tests (${scanResult.completedCount} completed).`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-slate-500 text-sm">Loading…</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Directories */}
|
||||
<section>
|
||||
<h3 className="text-slate-300 text-xs uppercase tracking-widest mb-3">Directories</h3>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ key: 'target_dir', label: 'Target Tests Directory' },
|
||||
{ key: 'results_dir', label: 'Results Directory' },
|
||||
].map(({ key, label }) => (
|
||||
<div key={key}>
|
||||
<label className="text-slate-400 text-xs block mb-1">{label}</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={form[key] ?? ''}
|
||||
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
|
||||
placeholder="C:\path\to\folder"
|
||||
className="flex-1 bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setBrowser(key)}
|
||||
className="px-3 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Avg Time Overrides */}
|
||||
<section>
|
||||
<h3 className="text-slate-300 text-xs uppercase tracking-widest mb-1">Avg Time Overrides</h3>
|
||||
<p className="text-slate-500 text-xs mb-3">
|
||||
Manually set avg minutes per test type. Leave blank to use calculated average from completed tests.
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{AVG_FIELDS.map(({ key, label }) => (
|
||||
<div key={key}>
|
||||
<label className="text-slate-400 text-xs block mb-1">{label}</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={form[key] ?? ''}
|
||||
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
|
||||
placeholder="auto"
|
||||
className="w-full bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 px-5 py-4 border-t border-slate-800">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isPending}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isPending ? 'Saving…' : 'Save & Rescan'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{browser && (
|
||||
<DirectoryBrowser
|
||||
onSelect={path => setForm(f => ({ ...f, [browser]: path }))}
|
||||
onClose={() => setBrowser(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState } from 'react'
|
||||
import { browse } from '../lib/api'
|
||||
|
||||
export default function DirectoryBrowser({ onSelect, onClose }) {
|
||||
const [current, setCurrent] = useState(null) // null = roots
|
||||
const [parent, setParent] = useState(null)
|
||||
const [dirs, setDirs] = useState(null) // null = not loaded yet
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
async function navigate(path) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await browse(path)
|
||||
setCurrent(data.path)
|
||||
setParent(data.parent)
|
||||
setDirs(data.dirs)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load roots on first render
|
||||
if (dirs === null && !loading && !error) {
|
||||
navigate(null)
|
||||
}
|
||||
|
||||
const breadcrumbs = current ? current.replace(/\\/g, '/').split('/').filter(Boolean) : []
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-lg mx-4 flex flex-col shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-800">
|
||||
<span className="text-slate-200 font-semibold text-sm">Browse Folder</span>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-200">✕</button>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="px-4 py-2 border-b border-slate-800 flex items-center gap-1 text-xs text-slate-400 flex-wrap min-h-[36px]">
|
||||
<button onClick={() => navigate(null)} className="hover:text-slate-200">Drives</button>
|
||||
{breadcrumbs.map((part, i) => {
|
||||
const path = breadcrumbs.slice(0, i + 1).join('\\') + (i === 0 ? '\\' : '')
|
||||
return (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<span>/</span>
|
||||
<button
|
||||
onClick={() => navigate(path)}
|
||||
className="hover:text-slate-200 truncate max-w-[120px]"
|
||||
title={path}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Directory list */}
|
||||
<div className="overflow-y-auto max-h-64 divide-y divide-slate-800">
|
||||
{loading && (
|
||||
<p className="text-slate-500 text-sm text-center py-8">Loading…</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm text-center py-8">{error}</p>
|
||||
)}
|
||||
{!loading && !error && parent !== null && (
|
||||
<button
|
||||
onClick={() => navigate(parent)}
|
||||
className="w-full text-left px-4 py-2.5 text-slate-400 hover:bg-slate-800 text-sm transition-colors"
|
||||
>
|
||||
↑ ..
|
||||
</button>
|
||||
)}
|
||||
{!loading && !error && dirs?.map(d => (
|
||||
<button
|
||||
key={d.path}
|
||||
onClick={() => navigate(d.path)}
|
||||
className="w-full text-left px-4 py-2.5 text-slate-300 hover:bg-slate-800 text-sm transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span className="text-slate-500">📁</span>
|
||||
{d.name}
|
||||
</button>
|
||||
))}
|
||||
{!loading && !error && dirs?.length === 0 && (
|
||||
<p className="text-slate-500 text-sm text-center py-8">No subdirectories</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-3 border-t border-slate-800">
|
||||
<p className="text-xs text-slate-500 truncate flex-1" title={current ?? ''}>
|
||||
{current ?? 'Select a folder'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
disabled={!current}
|
||||
onClick={() => { onSelect(current); onClose() }}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Select This Folder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
const FILTER_FIELDS = [
|
||||
{ key: 'completed', label: 'Status', options: [{ value: '', label: 'All' }, { value: 'true', label: 'Completed' }, { value: 'false', label: 'Pending' }] },
|
||||
{ key: 'interference', label: 'Type', options: [{ value: '', label: 'All' }, { value: 'COE', label: 'COE' }, { value: 'P2P', label: 'P2P' }, { value: 'P3P', label: 'P3P' }] },
|
||||
]
|
||||
|
||||
const DERIVED_FIELDS = [
|
||||
{ key: 'device', label: 'Device' },
|
||||
{ key: 'rotation', label: 'Rotation' },
|
||||
{ key: 'test_point', label: 'Test Point' },
|
||||
{ key: 'rssi', label: 'RSSI' },
|
||||
{ key: 'station', label: 'Station' },
|
||||
{ key: 'band', label: 'Band' },
|
||||
{ key: 'channel', label: 'Channel' },
|
||||
{ key: 'bandwidth', label: 'Bandwidth' },
|
||||
{ key: 'direction', label: 'Direction' },
|
||||
]
|
||||
|
||||
function unique(tests, key) {
|
||||
return [...new Set(tests.map(t => t[key]).filter(Boolean))].sort()
|
||||
}
|
||||
|
||||
export default function FilterPanel({ filters, onChange, allTests = [] }) {
|
||||
function set(key, value) {
|
||||
onChange({ ...filters, [key]: value })
|
||||
}
|
||||
|
||||
function reset() {
|
||||
onChange({})
|
||||
}
|
||||
|
||||
const hasActive = Object.values(filters).some(v => v !== '' && v != null)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{FILTER_FIELDS.map(({ key, label, options }) => (
|
||||
<select
|
||||
key={key}
|
||||
value={filters[key] ?? ''}
|
||||
onChange={e => set(key, e.target.value)}
|
||||
className="bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-1.5 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">{label}: All</option>
|
||||
{options.slice(1).map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
))}
|
||||
|
||||
{DERIVED_FIELDS.map(({ key, label }) => {
|
||||
const vals = unique(allTests, key)
|
||||
if (vals.length === 0) return null
|
||||
return (
|
||||
<select
|
||||
key={key}
|
||||
value={filters[key] ?? ''}
|
||||
onChange={e => set(key, e.target.value)}
|
||||
className="bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-1.5 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">{label}: All</option>
|
||||
{vals.map(v => <option key={v} value={v}>{v}</option>)}
|
||||
</select>
|
||||
)
|
||||
})}
|
||||
|
||||
{hasActive && (
|
||||
<button
|
||||
onClick={reset}
|
||||
className="text-slate-400 hover:text-slate-200 text-sm px-2 py-1.5 rounded-lg hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
✕ Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export default function StatCard({ label, value, sub, accent }) {
|
||||
return (
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 flex flex-col gap-1 min-w-0">
|
||||
<p className="text-slate-400 text-xs uppercase tracking-widest truncate">{label}</p>
|
||||
<p className={`text-3xl font-bold ${accent ?? 'text-slate-100'}`}>{value}</p>
|
||||
{sub && <p className="text-slate-400 text-sm">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default function StatusBadge({ completed }) {
|
||||
return completed
|
||||
? <span className="inline-flex items-center gap-1 text-emerald-400 text-sm font-medium">✓ Done</span>
|
||||
: <span className="inline-flex items-center gap-1 text-slate-500 text-sm">○ Pending</span>
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState } from 'react'
|
||||
import StatusBadge from './StatusBadge'
|
||||
|
||||
const COLS = [
|
||||
{ key: 'file_id', label: 'File ID' },
|
||||
{ key: 'interference', label: 'Type' },
|
||||
{ key: 'device', label: 'Device' },
|
||||
{ key: 'rotation', label: 'Rotation' },
|
||||
{ key: 'test_point', label: 'Test Point' },
|
||||
{ key: 'rssi', label: 'RSSI' },
|
||||
{ key: 'station', label: 'Station' },
|
||||
{ key: 'band', label: 'Band' },
|
||||
{ key: 'channel', label: 'Channel' },
|
||||
{ key: 'bandwidth', label: 'BW' },
|
||||
{ key: 'direction', label: 'Dir' },
|
||||
{ key: 'completed', label: 'Status' },
|
||||
{ key: 'duration_seconds', label: 'Duration' },
|
||||
]
|
||||
|
||||
function fmtDuration(s) {
|
||||
if (s == null) return '—'
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
const sec = Math.floor(s % 60)
|
||||
if (h > 0) return `${h}h ${m}m`
|
||||
if (m > 0) return `${m}m ${sec}s`
|
||||
return `${sec}s`
|
||||
}
|
||||
|
||||
export default function TestTable({ tests = [], isLoading }) {
|
||||
const [sort, setSort] = useState({ key: 'file_id', dir: 1 })
|
||||
|
||||
function toggleSort(key) {
|
||||
setSort(s => ({ key, dir: s.key === key ? -s.dir : 1 }))
|
||||
}
|
||||
|
||||
const sorted = [...tests].sort((a, b) => {
|
||||
let av = a[sort.key] ?? ''
|
||||
let bv = b[sort.key] ?? ''
|
||||
if (typeof av === 'number' || typeof bv === 'number') return (av - bv) * sort.dir
|
||||
return String(av).localeCompare(String(bv)) * sort.dir
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-slate-500">
|
||||
Loading tests…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (tests.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-slate-500">
|
||||
No tests match the current filters.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-800">
|
||||
<table className="w-full text-sm text-left text-slate-300 border-collapse">
|
||||
<thead className="bg-slate-800 text-slate-400 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
{COLS.map(col => (
|
||||
<th
|
||||
key={col.key}
|
||||
onClick={() => toggleSort(col.key)}
|
||||
className="px-3 py-3 cursor-pointer select-none whitespace-nowrap hover:text-slate-200 transition-colors"
|
||||
>
|
||||
{col.label}
|
||||
{sort.key === col.key && (
|
||||
<span className="ml-1">{sort.dir === 1 ? '↑' : '↓'}</span>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((test, i) => (
|
||||
<tr
|
||||
key={test.id}
|
||||
className={`border-t border-slate-800 transition-colors ${
|
||||
test.completed
|
||||
? 'bg-emerald-950/20 hover:bg-emerald-950/40'
|
||||
: i % 2 === 0 ? 'bg-slate-900 hover:bg-slate-800' : 'bg-slate-900/60 hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-xs text-slate-200 whitespace-nowrap">{test.file_id ?? '—'}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${
|
||||
test.interference === 'COE' ? 'bg-blue-900/50 text-blue-300' :
|
||||
test.interference === 'P2P' ? 'bg-purple-900/50 text-purple-300' :
|
||||
test.interference === 'P3P' ? 'bg-orange-900/50 text-orange-300' : ''
|
||||
}`}>
|
||||
{test.interference ?? '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{test.device ?? '—'}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{test.rotation ?? '—'}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{test.test_point ?? '—'}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{test.rssi ?? '—'}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{test.station ?? '—'}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{test.band ?? '—'}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{test.channel ?? '—'}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{test.bandwidth ?? '—'}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{test.direction ?? '—'}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap"><StatusBadge completed={test.completed} /></td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-slate-400">{fmtDuration(test.duration_seconds)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
function fmt(seconds) {
|
||||
if (seconds == null) return null
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (h > 0) return `${h}h ${m}m`
|
||||
return `${m}m`
|
||||
}
|
||||
|
||||
export default function TimeDisplay({ elapsedSeconds, estimatedRemainingSeconds, byType }) {
|
||||
const missingTypes = byType
|
||||
? Object.entries(byType)
|
||||
.filter(([, v]) => v.remaining > 0 && v.avgSeconds === null)
|
||||
.map(([t]) => t)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 flex flex-col gap-3">
|
||||
<div className="flex gap-6">
|
||||
<div>
|
||||
<p className="text-slate-400 text-xs uppercase tracking-widest">Time Elapsed</p>
|
||||
<p className="text-2xl font-bold text-slate-100 mt-0.5">
|
||||
{fmt(elapsedSeconds) ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-400 text-xs uppercase tracking-widest">Est. Remaining</p>
|
||||
<p className={`text-2xl font-bold mt-0.5 ${estimatedRemainingSeconds != null ? 'text-slate-100' : 'text-amber-400'}`}>
|
||||
{estimatedRemainingSeconds != null ? fmt(estimatedRemainingSeconds) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{missingTypes.length > 0 && (
|
||||
<p className="text-amber-400 text-xs">
|
||||
⚠ No completed {missingTypes.join('/')} tests yet — enter avg time in Settings to estimate.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user