frontend
This commit is contained in:
@@ -1,184 +1 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
+246
-119
@@ -1,122 +1,249 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import heroImg from './assets/hero.png'
|
||||
import './App.css'
|
||||
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 { api, groupScheduleItems } from './api'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||
<img src={reactLogo} className="framework" alt="React logo" />
|
||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>
|
||||
Edit <code>src/App.jsx</code> and save to test <code>HMR</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="counter"
|
||||
onClick={() => setCount((count) => count + 1)}
|
||||
>
|
||||
Count is {count}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img className="logo" src={viteLogo} alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://react.dev/" target="_blank">
|
||||
<img className="button-icon" src={reactLogo} alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</>
|
||||
)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function getMondayOfWeek(date) {
|
||||
const d = new Date(date)
|
||||
const day = d.getDay()
|
||||
d.setDate(d.getDate() + (day === 0 ? -6 : 1 - day))
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d
|
||||
}
|
||||
|
||||
export default App
|
||||
function toKey(d) { return d.toISOString().slice(0, 10) }
|
||||
|
||||
function addDays(date, n) {
|
||||
const d = new Date(date)
|
||||
d.setDate(d.getDate() + n)
|
||||
return d
|
||||
}
|
||||
|
||||
function getTonightWindowKeys() {
|
||||
const now = new Date()
|
||||
const hour = now.getHours()
|
||||
const today = new Date(now)
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
if (hour < 1) {
|
||||
return {
|
||||
shift3Date: toKey(addDays(today, -1)),
|
||||
shift1Date: toKey(today),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shift3Date: toKey(today),
|
||||
shift1Date: toKey(addDays(today, 1)),
|
||||
}
|
||||
}
|
||||
|
||||
function toTonightConfigRows(scheduleData) {
|
||||
const { shift3Date, shift1Date } = getTonightWindowKeys()
|
||||
const tonightTests = [
|
||||
...((scheduleData[shift3Date]?.shift3) ?? []),
|
||||
...((scheduleData[shift1Date]?.shift1) ?? []),
|
||||
]
|
||||
|
||||
const bySta = new Map()
|
||||
|
||||
for (const test of tonightTests) {
|
||||
const config = test.config ?? {}
|
||||
for (const entry of Object.values(config)) {
|
||||
const staRaw = entry?.sta
|
||||
const testPoint = entry?.test_point
|
||||
if (!staRaw || !testPoint) continue
|
||||
|
||||
for (const staPart of String(staRaw).split(',')) {
|
||||
const sta = staPart.trim().toUpperCase()
|
||||
if (!sta) continue
|
||||
|
||||
if (!bySta.has(sta)) {
|
||||
bySta.set(sta, { sta, points: new Set() })
|
||||
}
|
||||
bySta.get(sta).points.add(testPoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(bySta.values())
|
||||
.map((row) => ({
|
||||
sta: row.sta,
|
||||
testPoints: Array.from(row.points).sort(),
|
||||
}))
|
||||
.sort((a, b) => a.sta.localeCompare(b.sta))
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
p2pCoeCsvPath: '',
|
||||
p3pCsvPath: '',
|
||||
refResultDir: '',
|
||||
dutResultDir: '',
|
||||
testExclusion: '',
|
||||
holidays: '',
|
||||
startDateOverride: '',
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function App() {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [settings, setSettings] = useState(DEFAULT_SETTINGS)
|
||||
const [daytimeEnabled, setDaytimeEnabled] = useState(false)
|
||||
const [topPriority, setTopPriority] = useState('')
|
||||
const [lowestPriority, setLowestPriority] = 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 tonightConfigRows = useMemo(() => toTonightConfigRows(scheduleData), [scheduleData])
|
||||
|
||||
// Fetch schedule for the given weekStart (Monday)
|
||||
const fetchSchedule = useCallback(async (start) => {
|
||||
try {
|
||||
const data = await api.getScheduleWeek(toKey(start))
|
||||
setScheduleData(groupScheduleItems(data.items))
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch schedule:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// On mount: load settings + holidays + schedule for today's week
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
try {
|
||||
const [saved, holidayData] = await Promise.all([
|
||||
api.getSettings(),
|
||||
api.getHolidays(),
|
||||
])
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
...saved,
|
||||
holidays: (holidayData.dates ?? []).join(', '),
|
||||
}))
|
||||
} catch (e) {
|
||||
console.warn('Backend not reachable on load:', e.message)
|
||||
}
|
||||
await fetchSchedule(getMondayOfWeek(new Date()))
|
||||
}
|
||||
init()
|
||||
}, [fetchSchedule])
|
||||
|
||||
// Refetch whenever the displayed week changes
|
||||
useEffect(() => {
|
||||
fetchSchedule(weekStart)
|
||||
}, [weekStart, fetchSchedule])
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async function handleSaveSettings(newSettings) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await api.saveSettings(newSettings)
|
||||
|
||||
const holidayDates = (newSettings.holidays ?? '')
|
||||
.split(',').map(s => s.trim()).filter(Boolean)
|
||||
await api.saveHolidays(holidayDates)
|
||||
|
||||
if (newSettings.p2pCoeCsvPath?.trim()) {
|
||||
await api.loadCsv(newSettings.p2pCoeCsvPath.trim())
|
||||
}
|
||||
if (newSettings.p3pCsvPath?.trim()) {
|
||||
await api.loadCsv(newSettings.p3pCsvPath.trim())
|
||||
}
|
||||
|
||||
setSettings(newSettings)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemakeSchedule() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await api.compileSchedule({
|
||||
start_date: settings.startDateOverride?.trim() || null,
|
||||
daytime_testing_today: daytimeEnabled,
|
||||
top_priority_tests: topPriority.split(',').map(s => s.trim()).filter(Boolean),
|
||||
lowest_priority_tests: lowestPriority.split(',').map(s => s.trim()).filter(Boolean),
|
||||
rule: settings.testExclusion ?? '',
|
||||
})
|
||||
setCompletionDate(result.completion_date ?? null)
|
||||
await fetchSchedule(weekStart)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleRerunDecision(rerunDuringDay) {
|
||||
// TODO: POST /api/failed-tests/rerun-decision once backend endpoint exists
|
||||
console.log('Rerun during day:', rerunDuringDay)
|
||||
setFailedTests([])
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gray-950 text-gray-100">
|
||||
<Header onOpenSettings={() => setSettingsOpen(true)} />
|
||||
|
||||
{error && (
|
||||
<div className="px-6 py-2 bg-orange-900/60 border-b border-orange-700 text-orange-200 text-sm flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button className="ml-3 text-orange-400 hover:text-white" onClick={() => setError(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FailedBanner
|
||||
failedTests={failedTests}
|
||||
estimatedMinutes={0}
|
||||
onDecision={handleRerunDecision}
|
||||
/>
|
||||
|
||||
<main className="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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RightPanel
|
||||
completionDate={completionDate}
|
||||
daytimeEnabled={daytimeEnabled}
|
||||
onDaytimeEnabledChange={setDaytimeEnabled}
|
||||
topPriority={topPriority}
|
||||
onTopPriorityChange={setTopPriority}
|
||||
lowestPriority={lowestPriority}
|
||||
onLowestPriorityChange={setLowestPriority}
|
||||
onRemakeSchedule={handleRemakeSchedule}
|
||||
loading={loading}
|
||||
tonightConfigRows={tonightConfigRows}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<SettingsModal
|
||||
isOpen={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
settings={settings}
|
||||
onSave={handleSaveSettings}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
const BASE = '/api'
|
||||
|
||||
async function request(method, path, body) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
||||
throw new Error(err.detail || `HTTP ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Health
|
||||
health: () => request('GET', '/health'),
|
||||
|
||||
// Settings
|
||||
getSettings: () => request('GET', '/settings'),
|
||||
saveSettings: (settings) => request('POST', '/settings', { settings }),
|
||||
|
||||
// Holidays
|
||||
getHolidays: () => request('GET', '/holidays'),
|
||||
saveHolidays: (dates) => request('POST', '/holidays', { dates }),
|
||||
|
||||
// Tests
|
||||
loadCsv: (csv_path) => request('POST', '/tests/load', { csv_path }),
|
||||
|
||||
// Schedule
|
||||
compileSchedule: (opts) => request('POST', '/schedule/compile', opts),
|
||||
getScheduleWeek: (start) => request('GET', `/schedule/week?start=${start}`),
|
||||
}
|
||||
|
||||
// Transform the flat items array from GET /api/schedule/week into the
|
||||
// { "YYYY-MM-DD": { shift1: [], shift2: [], shift3: [] } } shape the Calendar expects.
|
||||
export function groupScheduleItems(items = []) {
|
||||
const out = {}
|
||||
for (const item of items) {
|
||||
const key = item.scheduled_date
|
||||
if (!out[key]) out[key] = { shift1: [], shift2: [], shift3: [] }
|
||||
const shiftKey = `shift${item.shift_index}`
|
||||
out[key][shiftKey].push(item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import DayColumn from './DayColumn'
|
||||
|
||||
function addDays(date, n) {
|
||||
const d = new Date(date)
|
||||
d.setDate(d.getDate() + n)
|
||||
return d
|
||||
}
|
||||
|
||||
function formatDateRange(monday) {
|
||||
const sunday = addDays(monday, 6)
|
||||
const opts = { month: 'short', day: 'numeric' }
|
||||
const startStr = monday.toLocaleDateString('en-US', opts)
|
||||
const endStr = sunday.toLocaleDateString('en-US', {
|
||||
month: sunday.getMonth() !== monday.getMonth() ? 'short' : undefined,
|
||||
day: 'numeric',
|
||||
})
|
||||
return `${startStr} – ${endStr}`
|
||||
}
|
||||
|
||||
function toDateKey(date) {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function getMondayOfWeek(date) {
|
||||
const d = new Date(date)
|
||||
const day = d.getDay()
|
||||
d.setDate(d.getDate() + (day === 0 ? -6 : 1 - day))
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d
|
||||
}
|
||||
|
||||
// Returns the two shifts that form the next/active test window.
|
||||
// Window = Shift 3 of day D → Shift 1 of day D+1
|
||||
function getNextTestWindow() {
|
||||
const now = new Date()
|
||||
const hour = now.getHours()
|
||||
const tod = new Date(now); tod.setHours(0, 0, 0, 0)
|
||||
|
||||
// midnight–1AM: still inside shift 3 that started yesterday
|
||||
if (hour < 1) {
|
||||
return { shift3Date: toDateKey(addDays(tod, -1)), shift1Date: toDateKey(tod) }
|
||||
}
|
||||
// 1AM–5PM: shift 1 finished or in daytime gap — next window starts at 5PM today
|
||||
// 5PM–midnight: currently inside shift 3 of today
|
||||
// Both cases: upcoming/active window is shift 3 today + shift 1 tomorrow
|
||||
return { shift3Date: toDateKey(tod), shift1Date: toDateKey(addDays(tod, 1)) }
|
||||
}
|
||||
|
||||
// scheduleData: { "YYYY-MM-DD": { shift1: [...], shift2: [...], shift3: [...] }, ... }
|
||||
export default function Calendar({ scheduleData = {}, daytimeDateKey = null, weekStart, onWeekChange }) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const activeWindow = getNextTestWindow()
|
||||
|
||||
const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i))
|
||||
|
||||
// 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)]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 h-full">
|
||||
{/* Week navigation */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => onWeekChange(addDays(weekStart, -7))}
|
||||
className="p-1 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
||||
title="Previous week"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm font-medium text-gray-300 min-w-36 text-center">
|
||||
{formatDateRange(weekStart)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onWeekChange(addDays(weekStart, 7))}
|
||||
className="p-1 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
||||
title="Next week"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onWeekChange(getMondayOfWeek(new Date()))}
|
||||
className="px-2 py-1 text-xs font-semibold text-gray-300 border border-gray-600 rounded hover:bg-gray-700 hover:text-white transition-colors"
|
||||
title="Jump to this week"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 7-day grid */}
|
||||
<div className="flex gap-1.5 flex-1 overflow-x-auto">
|
||||
{orderedDays.map((date) => {
|
||||
const key = toDateKey(date)
|
||||
const shifts = scheduleData[key] ?? { shift1: [], shift2: [], shift3: [] }
|
||||
const isToday = date.getTime() === today.getTime()
|
||||
const isWeekend = date.getDay() === 0 || date.getDay() === 6
|
||||
// Which shifts on this date are part of the active test window?
|
||||
const activeShifts = new Set()
|
||||
if (key === activeWindow.shift3Date) activeShifts.add(3)
|
||||
if (key === activeWindow.shift1Date) activeShifts.add(1)
|
||||
return (
|
||||
<DayColumn
|
||||
key={key}
|
||||
date={date}
|
||||
isToday={isToday}
|
||||
activeShifts={activeShifts}
|
||||
shifts={shifts}
|
||||
showShift2={isWeekend || (daytimeDateKey !== null && key === daytimeDateKey)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex gap-4 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" />Failed</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-yellow-700 inline-block" />Invalid</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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 }) {
|
||||
const dayName = DAY_NAMES[date.getDay()]
|
||||
const dayNum = date.getDate()
|
||||
const isWeekend = date.getDay() === 0 || date.getDay() === 6
|
||||
const hasActive = activeShifts.size > 0
|
||||
|
||||
// Shift 2 is visible if: weekend, holiday (showShift2), or daytime testing on
|
||||
const shift2Visible = isWeekend || showShift2
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-w-0 flex-1 rounded-lg border border-gray-700 bg-gray-800/40">
|
||||
{/* Day header — subtle today ring, no full-column highlight */}
|
||||
<div
|
||||
className={`text-center py-1.5 rounded-t-lg ${
|
||||
isToday ? 'bg-gray-600 text-white' : 'bg-gray-700/60 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide leading-none">
|
||||
{dayName}
|
||||
</p>
|
||||
<p className={`text-base font-bold leading-tight ${isToday ? 'text-white' : 'text-gray-100'}`}>
|
||||
{dayNum}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Shifts */}
|
||||
<div className="flex flex-col flex-1 px-0.5 py-1">
|
||||
<ShiftSlot label="12AM–9AM" tests={shifts.shift1} visible={true} active={activeShifts.has(1)} />
|
||||
<ShiftSlot label="9AM–5PM" tests={shifts.shift2} visible={true} active={activeShifts.has(2)} />
|
||||
<ShiftSlot label="5PM–12AM" tests={shifts.shift3} visible={true} active={activeShifts.has(3)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export default function FailedBanner({ failedTests, estimatedMinutes, onDecision }) {
|
||||
if (!failedTests || failedTests.length === 0) return null
|
||||
|
||||
const hours = Math.floor(estimatedMinutes / 60)
|
||||
const mins = estimatedMinutes % 60
|
||||
const timeStr = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-4 px-6 py-3 bg-red-900/70 border-b border-red-700 text-red-100">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-5 h-5 mt-0.5 shrink-0 text-red-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
<span className="font-semibold">{failedTests.length} test{failedTests.length !== 1 ? 's' : ''}</span>
|
||||
{' '}failed last night and require a rerun —{' '}
|
||||
<span className="font-mono text-red-200">{failedTests.join(', ')}</span>
|
||||
</p>
|
||||
<p className="text-sm text-red-300 mt-0.5">
|
||||
Estimated rerun time: <span className="font-semibold text-red-100">{timeStr}</span>
|
||||
{' '}— Rerun these tests during the day today?
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => onDecision(true)}
|
||||
className="px-3 py-1 text-sm font-medium bg-red-700 hover:bg-red-600 text-white rounded-md border border-red-500 transition-colors"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDecision(false)}
|
||||
className="px-3 py-1 text-sm font-medium bg-gray-700 hover:bg-gray-600 text-gray-100 rounded-md border border-gray-500 transition-colors"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
</h1>
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-300 bg-gray-800 border border-gray-600 rounded-md hover:bg-gray-700 hover:text-white transition-colors"
|
||||
title="Settings"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Settings
|
||||
</button>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
function formatCompletionDate(value) {
|
||||
if (!value) return null
|
||||
|
||||
const parsed = new Date(`${value}T00:00:00`)
|
||||
if (Number.isNaN(parsed.getTime())) return String(value)
|
||||
|
||||
return parsed.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export default function RightPanel({
|
||||
completionDate,
|
||||
daytimeEnabled,
|
||||
onDaytimeEnabledChange,
|
||||
topPriority,
|
||||
onTopPriorityChange,
|
||||
lowestPriority,
|
||||
onLowestPriorityChange,
|
||||
onRemakeSchedule,
|
||||
tonightConfigRows = [],
|
||||
loading = false,
|
||||
}) {
|
||||
const formattedCompletionDate = formatCompletionDate(completionDate)
|
||||
|
||||
return (
|
||||
<aside className="flex flex-col gap-4 w-60 shrink-0 pt-8">
|
||||
{/* Estimated Completion */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1">
|
||||
Est. Completion
|
||||
</p>
|
||||
<p className="text-lg font-bold text-white">
|
||||
{formattedCompletionDate ?? <span className="text-gray-500 text-sm font-normal">Not calculated</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Daytime testing toggle */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-500">
|
||||
Day Time Testing (Today)
|
||||
</p>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={daytimeEnabled}
|
||||
onClick={() => onDaytimeEnabledChange(!daytimeEnabled)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
|
||||
daytimeEnabled ? 'bg-blue-600' : 'bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform ${
|
||||
daytimeEnabled ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top priority */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1.5">
|
||||
Top Priority
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={topPriority}
|
||||
onChange={(e) => onTopPriorityChange(e.target.value)}
|
||||
placeholder="P2PRXAX001, COERXBE002…"
|
||||
className="w-full bg-gray-900 border border-gray-600 rounded-md px-2.5 py-1.5 text-xs text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500 resize-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remake schedule */}
|
||||
<button
|
||||
onClick={onRemakeSchedule}
|
||||
disabled={loading}
|
||||
className="w-full py-2 text-sm font-semibold text-white bg-blue-600 hover:bg-blue-500 border border-blue-500 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Working…' : 'Remake Schedule'}
|
||||
</button>
|
||||
|
||||
{/* Config for tonight */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1.5">
|
||||
Config for Today
|
||||
</p>
|
||||
{tonightConfigRows.length === 0 ? (
|
||||
<p className="text-xs text-gray-500">No STA placement mapping available for tonight.</p>
|
||||
) : (
|
||||
<div className="max-h-52 overflow-y-auto rounded border border-gray-700">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-900 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left text-gray-400 font-semibold px-2 py-1.5 border-b border-gray-700">STA</th>
|
||||
<th className="text-left text-gray-400 font-semibold px-2 py-1.5 border-b border-gray-700">Testpoint</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tonightConfigRows.map((row) => (
|
||||
<tr key={row.sta} className="odd:bg-gray-950 even:bg-gray-900/70">
|
||||
<td className="text-gray-200 font-semibold px-2 py-1.5 border-b border-gray-800">{row.sta}</td>
|
||||
<td className="text-gray-300 px-2 py-1.5 border-b border-gray-800">{row.testPoints.join(', ')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
function Field({ label, hint, children }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-300 mb-1">
|
||||
{label}
|
||||
{hint && <span className="ml-1.5 text-gray-500 font-normal">{hint}</span>}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const INPUT_CLS =
|
||||
'w-full bg-gray-900 border border-gray-600 rounded-md px-3 py-1.5 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono'
|
||||
|
||||
const TEXTAREA_CLS = INPUT_CLS + ' resize-none'
|
||||
|
||||
export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
|
||||
const [form, setForm] = useState({ ...settings })
|
||||
|
||||
// Sync if parent settings change while modal is closed
|
||||
useEffect(() => {
|
||||
if (!isOpen) setForm({ ...settings })
|
||||
}, [isOpen, settings])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
function set(key, value) {
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
onSave(form)
|
||||
onClose()
|
||||
}
|
||||
|
||||
function handleBackdropClick(e) {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="relative bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-xl max-h-[90vh] flex flex-col">
|
||||
{/* Modal header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-700">
|
||||
<h2 className="text-base font-semibold text-white">Settings</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-white transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div className="flex flex-col gap-5 overflow-y-auto px-5 py-5">
|
||||
|
||||
{/* Section: Paths */}
|
||||
<div>
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
|
||||
File Paths
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Field label="P2P / COE CSV">
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLS}
|
||||
placeholder="/path/to/p2p_coe_tests.csv"
|
||||
value={form.p2pCoeCsvPath ?? ''}
|
||||
onChange={(e) => set('p2pCoeCsvPath', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="P3P CSV">
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLS}
|
||||
placeholder="/path/to/p3p_tests.csv"
|
||||
value={form.p3pCsvPath ?? ''}
|
||||
onChange={(e) => set('p3pCsvPath', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="REF Result Directory">
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLS}
|
||||
placeholder="/path/to/ref/results"
|
||||
value={form.refResultDir ?? ''}
|
||||
onChange={(e) => set('refResultDir', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="DUT Result Directory">
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLS}
|
||||
placeholder="/path/to/dut/results"
|
||||
value={form.dutResultDir ?? ''}
|
||||
onChange={(e) => set('dutResultDir', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-gray-700" />
|
||||
|
||||
{/* Section: Test Exclusion */}
|
||||
<div>
|
||||
<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)">
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLS}
|
||||
placeholder="P2PRXAX001, COERXBE002…"
|
||||
value={form.testExclusion ?? ''}
|
||||
onChange={(e) => set('testExclusion', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<hr className="border-gray-700" />
|
||||
|
||||
{/* Section: Holidays */}
|
||||
<div>
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
|
||||
Holidays
|
||||
</p>
|
||||
<Field label="Holiday Dates" hint="(comma-separated, YYYY-MM-DD)">
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLS}
|
||||
placeholder="2026-07-04, 2026-12-25…"
|
||||
value={form.holidays ?? ''}
|
||||
onChange={(e) => set('holidays', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<hr className="border-gray-700" />
|
||||
|
||||
{/* Section: Schedule */}
|
||||
<div>
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
|
||||
Schedule
|
||||
</p>
|
||||
<Field label="Start Date Override" hint="(YYYY-MM-DD, leave blank for today)">
|
||||
<input
|
||||
type="date"
|
||||
className={INPUT_CLS}
|
||||
value={form.startDateOverride ?? ''}
|
||||
onChange={(e) => set('startDateOverride', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 px-5 py-4 border-t border-gray-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 text-sm text-gray-300 bg-gray-800 border border-gray-600 rounded-md hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-1.5 text-sm font-semibold text-white bg-blue-600 hover:bg-blue-500 border border-blue-500 rounded-md transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import TestCard from './TestCard'
|
||||
|
||||
export default function ShiftSlot({ label, tests = [], visible = true, active = false }) {
|
||||
if (!visible) return null
|
||||
|
||||
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 className="flex flex-col gap-0.5 px-1 min-h-4">
|
||||
{tests.length === 0 ? (
|
||||
<span className="text-[10px] text-gray-600 italic">—</span>
|
||||
) : (
|
||||
tests.map((test) => <TestCard key={`${test.test_id}-${test.device}`} test={test} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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',
|
||||
failed: 'bg-red-800/60 text-red-200 border-red-600',
|
||||
invalid: 'bg-yellow-800/60 text-yellow-200 border-yellow-600',
|
||||
}
|
||||
|
||||
export default function TestCard({ test }) {
|
||||
const style = STATUS_STYLES[test.status] ?? STATUS_STYLES.pending
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<div
|
||||
className={`px-1.5 py-0.5 rounded border text-xs font-mono truncate cursor-default select-none ${style}`}
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
{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">
|
||||
<p><span className="text-gray-400">Type:</span> {test.test_type ?? '—'}</p>
|
||||
<p><span className="text-gray-400">Rotation:</span> {test.rotation ?? '—'}</p>
|
||||
<p><span className="text-gray-400">Device:</span> {test.device ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
-111
@@ -1,111 +1 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
@import "tailwindcss";
|
||||
|
||||
Reference in New Issue
Block a user