clean up calendar display, added separated device runtime overrides
This commit is contained in:
+18
-10
@@ -44,7 +44,7 @@ def _smb_credentials_from_settings(settings: dict[str, Any]) -> dict[str, str]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _runtime_overrides_from_settings(settings: dict[str, Any]) -> dict[str, int]:
|
def _runtime_overrides_from_settings(settings: dict[str, Any]) -> dict[str, dict[str, int]]:
|
||||||
def _parse_positive_int(value: Any) -> int | None:
|
def _parse_positive_int(value: Any) -> int | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -58,15 +58,23 @@ def _runtime_overrides_from_settings(settings: dict[str, Any]) -> dict[str, int]
|
|||||||
return None
|
return None
|
||||||
return parsed if parsed > 0 else None
|
return parsed if parsed > 0 else None
|
||||||
|
|
||||||
overrides: dict[str, int] = {}
|
legacy_overrides = {
|
||||||
for test_type, key in (
|
"P2P": _parse_positive_int(settings.get("p2pRuntimeMinutes")),
|
||||||
("P2P", "p2pRuntimeMinutes"),
|
"COE": _parse_positive_int(settings.get("coeRuntimeMinutes")),
|
||||||
("COE", "coeRuntimeMinutes"),
|
"P3P": _parse_positive_int(settings.get("p3pRuntimeMinutes")),
|
||||||
("P3P", "p3pRuntimeMinutes"),
|
}
|
||||||
):
|
|
||||||
minutes = _parse_positive_int(settings.get(key))
|
overrides: dict[str, dict[str, int]] = {}
|
||||||
if minutes is not None:
|
for device_key, field_prefix in ((db.DEVICE_DUT, "dut"), (db.DEVICE_REF, "ref")):
|
||||||
overrides[test_type] = minutes
|
device_overrides: dict[str, int] = {}
|
||||||
|
for test_type, suffix in (("P2P", "P2p"), ("COE", "Coe"), ("P3P", "P3p")):
|
||||||
|
minutes = _parse_positive_int(settings.get(f"{field_prefix}{suffix}RuntimeMinutes"))
|
||||||
|
if minutes is None:
|
||||||
|
minutes = legacy_overrides[test_type]
|
||||||
|
if minutes is not None:
|
||||||
|
device_overrides[test_type] = minutes
|
||||||
|
if device_overrides:
|
||||||
|
overrides[device_key] = device_overrides
|
||||||
|
|
||||||
return overrides
|
return overrides
|
||||||
|
|
||||||
|
|||||||
+32
-9
@@ -236,13 +236,17 @@ def parse_target_csv(
|
|||||||
runtime_overrides: dict[str, Any] | None = None,
|
runtime_overrides: dict[str, Any] | None = None,
|
||||||
) -> ParseResult:
|
) -> ParseResult:
|
||||||
paths = _resolve_csv_paths(csv_path, smb_credentials=smb_credentials)
|
paths = _resolve_csv_paths(csv_path, smb_credentials=smb_credentials)
|
||||||
runtime_defaults = _resolve_runtime_defaults(runtime_overrides)
|
runtime_defaults_by_device = _resolve_runtime_defaults(runtime_overrides)
|
||||||
all_tests: list[TestRecord] = []
|
all_tests: list[TestRecord] = []
|
||||||
all_warnings: list[str] = []
|
all_warnings: list[str] = []
|
||||||
seen_test_keys: set[tuple[str, str]] = set()
|
seen_test_keys: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
for path in paths:
|
for path in paths:
|
||||||
parsed = _parse_single_csv(path, smb_credentials=smb_credentials, runtime_defaults=runtime_defaults)
|
parsed = _parse_single_csv(
|
||||||
|
path,
|
||||||
|
smb_credentials=smb_credentials,
|
||||||
|
runtime_defaults_by_device=runtime_defaults_by_device,
|
||||||
|
)
|
||||||
all_warnings.extend(parsed.warnings)
|
all_warnings.extend(parsed.warnings)
|
||||||
for record in parsed.tests:
|
for record in parsed.tests:
|
||||||
key = (record.test_id, record.device)
|
key = (record.test_id, record.device)
|
||||||
@@ -257,13 +261,13 @@ def parse_target_csv(
|
|||||||
return ParseResult(tests=all_tests, warnings=all_warnings)
|
return ParseResult(tests=all_tests, warnings=all_warnings)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_runtime_defaults(runtime_overrides: dict[str, Any] | None) -> dict[str, int]:
|
def _coerce_runtime_defaults(raw_overrides: dict[str, Any] | None) -> dict[str, int]:
|
||||||
defaults = dict(RUNTIME_DEFAULTS)
|
defaults = dict(RUNTIME_DEFAULTS)
|
||||||
if not runtime_overrides:
|
if not raw_overrides:
|
||||||
return defaults
|
return defaults
|
||||||
|
|
||||||
for test_type in ("P2P", "COE", "P3P"):
|
for test_type in ("P2P", "COE", "P3P"):
|
||||||
raw_value = runtime_overrides.get(test_type)
|
raw_value = raw_overrides.get(test_type)
|
||||||
if raw_value is None:
|
if raw_value is None:
|
||||||
continue
|
continue
|
||||||
if isinstance(raw_value, str):
|
if isinstance(raw_value, str):
|
||||||
@@ -280,6 +284,25 @@ def _resolve_runtime_defaults(runtime_overrides: dict[str, Any] | None) -> dict[
|
|||||||
return defaults
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_runtime_defaults(runtime_overrides: dict[str, Any] | None) -> dict[str, dict[str, int]]:
|
||||||
|
legacy_defaults = _coerce_runtime_defaults(runtime_overrides)
|
||||||
|
if not runtime_overrides:
|
||||||
|
return {
|
||||||
|
DEVICE_DUT: dict(legacy_defaults),
|
||||||
|
DEVICE_REF: dict(legacy_defaults),
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved: dict[str, dict[str, int]] = {}
|
||||||
|
for device in (DEVICE_DUT, DEVICE_REF):
|
||||||
|
device_defaults = dict(legacy_defaults)
|
||||||
|
raw_device_overrides = runtime_overrides.get(device)
|
||||||
|
if isinstance(raw_device_overrides, dict):
|
||||||
|
device_defaults.update(_coerce_runtime_defaults(raw_device_overrides))
|
||||||
|
resolved[device] = device_defaults
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_csv_paths(
|
def _resolve_csv_paths(
|
||||||
csv_path: str | Path | list[str | Path] | tuple[str | Path, ...],
|
csv_path: str | Path | list[str | Path] | tuple[str | Path, ...],
|
||||||
@@ -318,7 +341,7 @@ def _open_csv_handle(path: str, smb_credentials: dict[str, Any] | None):
|
|||||||
def _parse_single_csv(
|
def _parse_single_csv(
|
||||||
path: str,
|
path: str,
|
||||||
smb_credentials: dict[str, Any] | None,
|
smb_credentials: dict[str, Any] | None,
|
||||||
runtime_defaults: dict[str, int],
|
runtime_defaults_by_device: dict[str, dict[str, int]],
|
||||||
) -> ParseResult:
|
) -> ParseResult:
|
||||||
with _open_csv_handle(path, smb_credentials=smb_credentials) as handle:
|
with _open_csv_handle(path, smb_credentials=smb_credentials) as handle:
|
||||||
reader = csv.DictReader(handle)
|
reader = csv.DictReader(handle)
|
||||||
@@ -352,7 +375,6 @@ def _parse_single_csv(
|
|||||||
has_coe_pair = _normalize_yes_no(_row_get(row, "COE Pair")) if csv_format == "p2p_coe" else False
|
has_coe_pair = _normalize_yes_no(_row_get(row, "COE Pair")) if csv_format == "p2p_coe" else False
|
||||||
config = _build_config(row, csv_format)
|
config = _build_config(row, csv_format)
|
||||||
signature = _victim_band_signature(row) if csv_format == "p2p_coe" else None
|
signature = _victim_band_signature(row) if csv_format == "p2p_coe" else None
|
||||||
estimated_minutes = runtime_defaults.get(test_type, RUNTIME_DEFAULTS[test_type])
|
|
||||||
victim_band_source = "Victim Band" if csv_format == "p2p_coe" else "Band"
|
victim_band_source = "Victim Band" if csv_format == "p2p_coe" else "Band"
|
||||||
victim_band = _normalize_victim_band(_row_get(row, victim_band_source))
|
victim_band = _normalize_victim_band(_row_get(row, victim_band_source))
|
||||||
|
|
||||||
@@ -376,7 +398,7 @@ def _parse_single_csv(
|
|||||||
victim_band=victim_band,
|
victim_band=victim_band,
|
||||||
config=config,
|
config=config,
|
||||||
throttled=throttled,
|
throttled=throttled,
|
||||||
estimated_minutes=estimated_minutes,
|
estimated_minutes=RUNTIME_DEFAULTS[test_type],
|
||||||
status="pending",
|
status="pending",
|
||||||
excluded=False,
|
excluded=False,
|
||||||
raw_payload=row,
|
raw_payload=row,
|
||||||
@@ -401,6 +423,7 @@ def _parse_single_csv(
|
|||||||
pairs = sorted(coe_by_signature_and_suffix.get(key, [])) if key else []
|
pairs = sorted(coe_by_signature_and_suffix.get(key, [])) if key else []
|
||||||
|
|
||||||
for device in (DEVICE_DUT, DEVICE_REF):
|
for device in (DEVICE_DUT, DEVICE_REF):
|
||||||
|
device_runtime_defaults = runtime_defaults_by_device.get(device, RUNTIME_DEFAULTS)
|
||||||
tests.append(
|
tests.append(
|
||||||
TestRecord(
|
TestRecord(
|
||||||
test_id=record.test_id,
|
test_id=record.test_id,
|
||||||
@@ -414,7 +437,7 @@ def _parse_single_csv(
|
|||||||
victim_band=record.victim_band,
|
victim_band=record.victim_band,
|
||||||
config=record.config,
|
config=record.config,
|
||||||
throttled=record.throttled,
|
throttled=record.throttled,
|
||||||
estimated_minutes=record.estimated_minutes,
|
estimated_minutes=device_runtime_defaults.get(record.test_type, RUNTIME_DEFAULTS[record.test_type]),
|
||||||
status=record.status,
|
status=record.status,
|
||||||
excluded=record.excluded,
|
excluded=record.excluded,
|
||||||
raw_payload=record.raw_payload,
|
raw_payload=record.raw_payload,
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.append(str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from db import DEVICE_DUT, DEVICE_REF
|
||||||
|
from parser import P2P_COE_REQUIRED_COLUMNS, parse_target_csv
|
||||||
|
|
||||||
|
|
||||||
|
def _write_p2p_csv(directory: str, test_id: str = "P2PRXAX001") -> Path:
|
||||||
|
path = Path(directory) / "runtime-overrides.csv"
|
||||||
|
row = {column: "" for column in P2P_COE_REQUIRED_COLUMNS}
|
||||||
|
row["Priority"] = "1"
|
||||||
|
row["Index"] = "1"
|
||||||
|
row["TC ID"] = test_id
|
||||||
|
|
||||||
|
with path.open("w", encoding="utf-8", newline="") as handle:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=P2P_COE_REQUIRED_COLUMNS)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerow(row)
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeOverridesParseTests(unittest.TestCase):
|
||||||
|
def test_parse_target_csv_applies_device_specific_runtime_overrides(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
csv_path = _write_p2p_csv(tmpdir)
|
||||||
|
|
||||||
|
parsed = parse_target_csv(
|
||||||
|
csv_path,
|
||||||
|
runtime_overrides={
|
||||||
|
DEVICE_DUT: {"P2P": 91},
|
||||||
|
DEVICE_REF: {"P2P": 123},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
by_device = {record.device: record for record in parsed.tests}
|
||||||
|
self.assertEqual(by_device[DEVICE_DUT].estimated_minutes, 91)
|
||||||
|
self.assertEqual(by_device[DEVICE_REF].estimated_minutes, 123)
|
||||||
|
|
||||||
|
def test_parse_target_csv_keeps_legacy_runtime_override_for_both_devices(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
csv_path = _write_p2p_csv(tmpdir)
|
||||||
|
|
||||||
|
parsed = parse_target_csv(
|
||||||
|
csv_path,
|
||||||
|
runtime_overrides={"P2P": 88},
|
||||||
|
)
|
||||||
|
|
||||||
|
by_device = {record.device: record for record in parsed.tests}
|
||||||
|
self.assertEqual(by_device[DEVICE_DUT].estimated_minutes, 88)
|
||||||
|
self.assertEqual(by_device[DEVICE_REF].estimated_minutes, 88)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+21
-4
@@ -127,17 +127,34 @@ const DEFAULT_SETTINGS = {
|
|||||||
smbUsername: '',
|
smbUsername: '',
|
||||||
smbPassword: '',
|
smbPassword: '',
|
||||||
smbDomain: '',
|
smbDomain: '',
|
||||||
p2pRuntimeMinutes: '',
|
dutP2pRuntimeMinutes: '',
|
||||||
coeRuntimeMinutes: '',
|
dutCoeRuntimeMinutes: '',
|
||||||
p3pRuntimeMinutes: '',
|
dutP3pRuntimeMinutes: '',
|
||||||
|
refP2pRuntimeMinutes: '',
|
||||||
|
refCoeRuntimeMinutes: '',
|
||||||
|
refP3pRuntimeMinutes: '',
|
||||||
testExclusion: '',
|
testExclusion: '',
|
||||||
holidays: '',
|
holidays: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeSettings(saved = {}) {
|
function sanitizeSettings(saved = {}) {
|
||||||
const { startDateOverride: _ignored, ...rest } = saved
|
const {
|
||||||
|
p2pRuntimeMinutes,
|
||||||
|
coeRuntimeMinutes,
|
||||||
|
p3pRuntimeMinutes,
|
||||||
|
...rest
|
||||||
|
} = saved
|
||||||
|
|
||||||
|
delete rest.startDateOverride
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
|
dutP2pRuntimeMinutes: rest.dutP2pRuntimeMinutes ?? p2pRuntimeMinutes ?? '',
|
||||||
|
dutCoeRuntimeMinutes: rest.dutCoeRuntimeMinutes ?? coeRuntimeMinutes ?? '',
|
||||||
|
dutP3pRuntimeMinutes: rest.dutP3pRuntimeMinutes ?? p3pRuntimeMinutes ?? '',
|
||||||
|
refP2pRuntimeMinutes: rest.refP2pRuntimeMinutes ?? p2pRuntimeMinutes ?? '',
|
||||||
|
refCoeRuntimeMinutes: rest.refCoeRuntimeMinutes ?? coeRuntimeMinutes ?? '',
|
||||||
|
refP3pRuntimeMinutes: rest.refP3pRuntimeMinutes ?? p3pRuntimeMinutes ?? '',
|
||||||
...rest,
|
...rest,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
import DayColumn from './DayColumn'
|
import DayColumn from './DayColumn'
|
||||||
import { getDeviceAccentClass } from './TestCard'
|
import { getDeviceAccentClass } from './TestCard'
|
||||||
|
|
||||||
@@ -58,6 +59,11 @@ export default function Calendar({
|
|||||||
windowLookup = new Map(),
|
windowLookup = new Map(),
|
||||||
onWindowSelect,
|
onWindowSelect,
|
||||||
}) {
|
}) {
|
||||||
|
const SLOT_CHROME_PX = 10
|
||||||
|
const TEST_CARD_ROW_HEIGHT_PX = 22
|
||||||
|
const TEST_CARD_ROW_GAP_PX = 2
|
||||||
|
const DAY_HEADER_HEIGHT_PX = 48
|
||||||
|
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
today.setHours(0, 0, 0, 0)
|
today.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
@@ -68,6 +74,36 @@ export default function Calendar({
|
|||||||
// Reorder columns: Sun Mon Tue Wed Thu Fri Sat
|
// Reorder columns: Sun Mon Tue Wed Thu Fri Sat
|
||||||
// weekStart is Monday, so days[0]=Mon ... days[6]=Sun → put Sunday first
|
// weekStart is Monday, so days[0]=Mon ... days[6]=Sun → put Sunday first
|
||||||
const orderedDays = [ ...days.slice(0, 7)]
|
const orderedDays = [ ...days.slice(0, 7)]
|
||||||
|
const shiftMinRows = useMemo(() => {
|
||||||
|
const minima = { shift2: 1, shift3: 1, nextDayShift1: 1 }
|
||||||
|
|
||||||
|
for (const date of orderedDays) {
|
||||||
|
const key = toDateKey(date)
|
||||||
|
const shifts = scheduleData[key] ?? { shift1: [], shift2: [], shift3: [] }
|
||||||
|
const nextKey = toDateKey(addDays(date, 1))
|
||||||
|
const nextDayShift1 = scheduleData[nextKey]?.shift1 ?? []
|
||||||
|
|
||||||
|
minima.shift2 = Math.max(minima.shift2, shifts.shift2.length || 1)
|
||||||
|
minima.shift3 = Math.max(minima.shift3, shifts.shift3.length || 1)
|
||||||
|
minima.nextDayShift1 = Math.max(minima.nextDayShift1, nextDayShift1.length || 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return minima
|
||||||
|
}, [orderedDays, scheduleData])
|
||||||
|
const slotHeights = useMemo(() => {
|
||||||
|
const calcSlotHeight = (rows) => {
|
||||||
|
const safeRows = Math.max(1, rows || 1)
|
||||||
|
const contentHeight = (safeRows * TEST_CARD_ROW_HEIGHT_PX) + ((safeRows - 1) * TEST_CARD_ROW_GAP_PX)
|
||||||
|
return contentHeight + SLOT_CHROME_PX
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shift2: calcSlotHeight(shiftMinRows.shift2),
|
||||||
|
shift3: calcSlotHeight(shiftMinRows.shift3),
|
||||||
|
nextDayShift1: calcSlotHeight(shiftMinRows.nextDayShift1),
|
||||||
|
}
|
||||||
|
}, [shiftMinRows])
|
||||||
|
const totalShiftHeightPx = slotHeights.shift2 + slotHeights.shift3 + slotHeights.nextDayShift1
|
||||||
const deviceLegendItems = Array.from(
|
const deviceLegendItems = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
Object.values(scheduleData)
|
Object.values(scheduleData)
|
||||||
@@ -135,37 +171,50 @@ export default function Calendar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 7-day grid */}
|
{/* 7-day grid */}
|
||||||
<div className="flex gap-1.5 flex-1 overflow-x-auto">
|
<div className="flex gap-2 flex-1 min-h-0 overflow-hidden">
|
||||||
{orderedDays.map((date) => {
|
<div className="w-10 shrink-0 text-[10px] font-semibold uppercase tracking-wider text-gray-500">
|
||||||
const key = toDateKey(date)
|
<div style={{ height: `${DAY_HEADER_HEIGHT_PX}px` }} />
|
||||||
const shifts = scheduleData[key] ?? { shift1: [], shift2: [], shift3: [] }
|
<div className="relative" style={{ height: `${totalShiftHeightPx}px` }}>
|
||||||
const isToday = date.getTime() === today.getTime()
|
<span className="absolute left-0 top-0">9AM</span>
|
||||||
const isWeekend = date.getDay() === 0 || date.getDay() === 6
|
<span className="absolute left-0" style={{ top: `${slotHeights.shift2}px` }}>5PM</span>
|
||||||
// Get next day's shift1 for display
|
<span className="absolute left-0" style={{ top: `${slotHeights.shift2 + slotHeights.shift3}px` }}>12AM</span>
|
||||||
const nextDate = addDays(date, 1)
|
</div>
|
||||||
const nextKey = toDateKey(nextDate)
|
</div>
|
||||||
const nextDayShift1 = scheduleData[nextKey]?.shift1 ?? []
|
|
||||||
// Which shifts on this date are part of the active test window?
|
<div className="flex gap-1.5 flex-1 overflow-x-auto">
|
||||||
const activeShifts = new Set()
|
{orderedDays.map((date) => {
|
||||||
if (key === activeWindow.shift3Date) activeShifts.add(3)
|
const key = toDateKey(date)
|
||||||
if (key === activeWindow.shift1Date) activeShifts.add(1)
|
const shifts = scheduleData[key] ?? { shift1: [], shift2: [], shift3: [] }
|
||||||
// Check if next day's shift1 is active
|
const isToday = date.getTime() === today.getTime()
|
||||||
const nextDayShift1Active = nextKey === activeWindow.shift1Date
|
const isWeekend = date.getDay() === 0 || date.getDay() === 6
|
||||||
return (
|
// Get next day's shift1 for display
|
||||||
<DayColumn
|
const nextDate = addDays(date, 1)
|
||||||
key={key}
|
const nextKey = toDateKey(nextDate)
|
||||||
date={date}
|
const nextDayShift1 = scheduleData[nextKey]?.shift1 ?? []
|
||||||
isToday={isToday}
|
// Which shifts on this date are part of the active test window?
|
||||||
activeShifts={activeShifts}
|
const activeShifts = new Set()
|
||||||
shifts={shifts}
|
if (key === activeWindow.shift3Date) activeShifts.add(3)
|
||||||
nextDayShift1={nextDayShift1}
|
if (key === activeWindow.shift1Date) activeShifts.add(1)
|
||||||
nextDayShift1Active={nextDayShift1Active}
|
// Check if next day's shift1 is active
|
||||||
showShift2={isWeekend || (daytimeDateKey !== null && key === daytimeDateKey)}
|
const nextDayShift1Active = nextKey === activeWindow.shift1Date
|
||||||
windowLookup={windowLookup}
|
return (
|
||||||
onWindowSelect={onWindowSelect}
|
<DayColumn
|
||||||
/>
|
key={key}
|
||||||
)
|
date={date}
|
||||||
})}
|
isToday={isToday}
|
||||||
|
activeShifts={activeShifts}
|
||||||
|
shifts={shifts}
|
||||||
|
nextDayShift1={nextDayShift1}
|
||||||
|
nextDayShift1Active={nextDayShift1Active}
|
||||||
|
shiftMinRows={shiftMinRows}
|
||||||
|
slotHeights={slotHeights}
|
||||||
|
showShift2={isWeekend || (daytimeDateKey !== null && key === daytimeDateKey)}
|
||||||
|
windowLookup={windowLookup}
|
||||||
|
onWindowSelect={onWindowSelect}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export default function DayColumn({
|
|||||||
nextDayShift1 = [],
|
nextDayShift1 = [],
|
||||||
nextDayShift1Active = false,
|
nextDayShift1Active = false,
|
||||||
showShift2 = false,
|
showShift2 = false,
|
||||||
|
shiftMinRows = { shift2: 1, shift3: 1, nextDayShift1: 1 },
|
||||||
|
slotHeights = { shift2: 0, shift3: 0, nextDayShift1: 0 },
|
||||||
windowLookup = new Map(),
|
windowLookup = new Map(),
|
||||||
onWindowSelect,
|
onWindowSelect,
|
||||||
}) {
|
}) {
|
||||||
@@ -27,7 +29,7 @@ export default function DayColumn({
|
|||||||
<div className="flex flex-col min-w-0 flex-1 rounded-lg border border-gray-700 bg-gray-800/40">
|
<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 */}
|
{/* Day header — subtle today ring, no full-column highlight */}
|
||||||
<div
|
<div
|
||||||
className={`text-center py-1.5 rounded-t-lg ${
|
className={`h-12 flex flex-col justify-center text-center py-1.5 rounded-t-lg ${
|
||||||
isToday ? 'bg-gray-600 text-white' : 'bg-gray-700/60 text-gray-300'
|
isToday ? 'bg-gray-600 text-white' : 'bg-gray-700/60 text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -40,29 +42,32 @@ export default function DayColumn({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shifts */}
|
{/* Shifts */}
|
||||||
<div className="flex flex-col flex-1 px-0.5 py-1">
|
<div className="flex flex-col flex-1 px-0.5">
|
||||||
<ShiftSlot
|
<ShiftSlot
|
||||||
label="9AM–5PM"
|
|
||||||
tests={shifts.shift2}
|
tests={shifts.shift2}
|
||||||
visible={true}
|
visible={true}
|
||||||
active={activeShifts.has(2)}
|
active={activeShifts.has(2)}
|
||||||
windowDetails={getWindowForShift(2)}
|
windowDetails={getWindowForShift(2)}
|
||||||
|
minContentRows={shiftMinRows.shift2}
|
||||||
|
slotHeightPx={slotHeights.shift2}
|
||||||
onWindowSelect={onWindowSelect}
|
onWindowSelect={onWindowSelect}
|
||||||
/>
|
/>
|
||||||
<ShiftSlot
|
<ShiftSlot
|
||||||
label="5PM–12AM"
|
|
||||||
tests={shifts.shift3}
|
tests={shifts.shift3}
|
||||||
visible={true}
|
visible={true}
|
||||||
active={activeShifts.has(3)}
|
active={activeShifts.has(3)}
|
||||||
windowDetails={getWindowForShift(3)}
|
windowDetails={getWindowForShift(3)}
|
||||||
|
minContentRows={shiftMinRows.shift3}
|
||||||
|
slotHeightPx={slotHeights.shift3}
|
||||||
onWindowSelect={onWindowSelect}
|
onWindowSelect={onWindowSelect}
|
||||||
/>
|
/>
|
||||||
<ShiftSlot
|
<ShiftSlot
|
||||||
label="12AM–9AM (next day)"
|
|
||||||
tests={nextDayShift1}
|
tests={nextDayShift1}
|
||||||
visible={true}
|
visible={true}
|
||||||
active={nextDayShift1Active}
|
active={nextDayShift1Active}
|
||||||
windowDetails={null}
|
windowDetails={null}
|
||||||
|
minContentRows={shiftMinRows.nextDayShift1}
|
||||||
|
slotHeightPx={slotHeights.nextDayShift1}
|
||||||
onWindowSelect={onWindowSelect}
|
onWindowSelect={onWindowSelect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -152,40 +152,84 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
|
|||||||
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
|
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
|
||||||
Runtime Overrides
|
Runtime Overrides
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<div className="flex flex-col gap-4">
|
||||||
<Field label="P2P Minutes">
|
<div>
|
||||||
<input
|
<p className="text-xs font-semibold text-gray-300 mb-2">DUT</p>
|
||||||
type="number"
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
min="1"
|
<Field label="P2P Minutes">
|
||||||
step="1"
|
<input
|
||||||
className={INPUT_CLS}
|
type="number"
|
||||||
placeholder="80"
|
min="1"
|
||||||
value={form.p2pRuntimeMinutes ?? ''}
|
step="1"
|
||||||
onChange={(e) => set('p2pRuntimeMinutes', e.target.value)}
|
className={INPUT_CLS}
|
||||||
/>
|
placeholder="80"
|
||||||
</Field>
|
value={form.dutP2pRuntimeMinutes ?? ''}
|
||||||
<Field label="COE Minutes">
|
onChange={(e) => set('dutP2pRuntimeMinutes', e.target.value)}
|
||||||
<input
|
/>
|
||||||
type="number"
|
</Field>
|
||||||
min="1"
|
<Field label="COE Minutes">
|
||||||
step="1"
|
<input
|
||||||
className={INPUT_CLS}
|
type="number"
|
||||||
placeholder="115"
|
min="1"
|
||||||
value={form.coeRuntimeMinutes ?? ''}
|
step="1"
|
||||||
onChange={(e) => set('coeRuntimeMinutes', e.target.value)}
|
className={INPUT_CLS}
|
||||||
/>
|
placeholder="115"
|
||||||
</Field>
|
value={form.dutCoeRuntimeMinutes ?? ''}
|
||||||
<Field label="P3P Minutes">
|
onChange={(e) => set('dutCoeRuntimeMinutes', e.target.value)}
|
||||||
<input
|
/>
|
||||||
type="number"
|
</Field>
|
||||||
min="1"
|
<Field label="P3P Minutes">
|
||||||
step="1"
|
<input
|
||||||
className={INPUT_CLS}
|
type="number"
|
||||||
placeholder="105"
|
min="1"
|
||||||
value={form.p3pRuntimeMinutes ?? ''}
|
step="1"
|
||||||
onChange={(e) => set('p3pRuntimeMinutes', e.target.value)}
|
className={INPUT_CLS}
|
||||||
/>
|
placeholder="105"
|
||||||
</Field>
|
value={form.dutP3pRuntimeMinutes ?? ''}
|
||||||
|
onChange={(e) => set('dutP3pRuntimeMinutes', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-300 mb-2">REF</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<Field label="P2P Minutes">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
className={INPUT_CLS}
|
||||||
|
placeholder="80"
|
||||||
|
value={form.refP2pRuntimeMinutes ?? ''}
|
||||||
|
onChange={(e) => set('refP2pRuntimeMinutes', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="COE Minutes">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
className={INPUT_CLS}
|
||||||
|
placeholder="115"
|
||||||
|
value={form.refCoeRuntimeMinutes ?? ''}
|
||||||
|
onChange={(e) => set('refCoeRuntimeMinutes', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="P3P Minutes">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
className={INPUT_CLS}
|
||||||
|
placeholder="105"
|
||||||
|
value={form.refP3pRuntimeMinutes ?? ''}
|
||||||
|
onChange={(e) => set('refP3pRuntimeMinutes', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import TestCard from './TestCard'
|
import TestCard from './TestCard'
|
||||||
|
|
||||||
|
const TEST_CARD_ROW_HEIGHT_PX = 22
|
||||||
|
const TEST_CARD_ROW_GAP_PX = 2
|
||||||
|
|
||||||
export default function ShiftSlot({
|
export default function ShiftSlot({
|
||||||
label,
|
|
||||||
tests = [],
|
tests = [],
|
||||||
visible = true,
|
visible = true,
|
||||||
active = false,
|
active = false,
|
||||||
windowDetails = null,
|
windowDetails = null,
|
||||||
|
minContentRows = 1,
|
||||||
|
slotHeightPx = null,
|
||||||
onWindowSelect,
|
onWindowSelect,
|
||||||
}) {
|
}) {
|
||||||
if (!visible) return null
|
if (!visible) return null
|
||||||
|
|
||||||
const clickable = Boolean(windowDetails && onWindowSelect)
|
const clickable = Boolean(windowDetails && onWindowSelect)
|
||||||
|
const targetRows = Math.max(1, minContentRows)
|
||||||
|
const minContentHeightPx = (targetRows * TEST_CARD_ROW_HEIGHT_PX) + ((targetRows - 1) * TEST_CARD_ROW_GAP_PX)
|
||||||
|
const outerMinHeightPx = Number.isFinite(slotHeightPx) && slotHeightPx > 0
|
||||||
|
? slotHeightPx
|
||||||
|
: minContentHeightPx + 10
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -23,26 +32,16 @@ export default function ShiftSlot({
|
|||||||
onWindowSelect(windowDetails)
|
onWindowSelect(windowDetails)
|
||||||
}
|
}
|
||||||
} : undefined}
|
} : undefined}
|
||||||
|
style={{ minHeight: `${outerMinHeightPx}px` }}
|
||||||
className={`border-t pt-1 pb-1.5 transition-colors ${
|
className={`border-t pt-1 pb-1.5 transition-colors ${
|
||||||
active ? 'border-blue-500/50 bg-blue-950/20 rounded' : 'border-gray-700/60'
|
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' : ''}`}
|
} ${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">
|
<div
|
||||||
<p className={`text-[10px] font-semibold uppercase tracking-wider ${
|
className="flex flex-col gap-0.5 px-1 min-h-4"
|
||||||
active ? 'text-blue-400' : 'text-gray-500'
|
style={{ minHeight: `${minContentHeightPx}px` }}
|
||||||
}`}>
|
>
|
||||||
{label}
|
{tests.length === 0 ? null : (
|
||||||
</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>
|
|
||||||
) : (
|
|
||||||
tests.map((test) => <TestCard key={`${test.test_id}-${test.device}`} test={test} />)
|
tests.map((test) => <TestCard key={`${test.test_id}-${test.device}`} test={test} />)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user