This commit is contained in:
2026-06-17 16:02:04 -04:00
parent 6762586e2d
commit e51a6777fc
21 changed files with 1399 additions and 506 deletions
+130
View File
@@ -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)
// midnight1AM: still inside shift 3 that started yesterday
if (hour < 1) {
return { shift3Date: toDateKey(addDays(tod, -1)), shift1Date: toDateKey(tod) }
}
// 1AM5PM: shift 1 finished or in daytime gap — next window starts at 5PM today
// 5PMmidnight: 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>
)
}
+38
View File
@@ -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="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)} />
</div>
</div>
)
}
+51
View File
@@ -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>
)
}
+35
View File
@@ -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>
)
}
+117
View File
@@ -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>
)
}
+183
View File
@@ -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>
)
}
+24
View File
@@ -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>
)
}
+29
View File
@@ -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>
)
}