diff --git a/.gitignore b/.gitignore index b479b1c..8c143c6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ /backend/.env IMPLEMENTATIONPLAN.md scheduler.db -scheduler.db* \ No newline at end of file +scheduler.db* +TARGET_TEST_DIR_STRUCTURE.md +.env \ No newline at end of file diff --git a/backend/app.py b/backend/app.py index 7093f72..d92c914 100644 --- a/backend/app.py +++ b/backend/app.py @@ -452,21 +452,42 @@ def get_holidays() -> dict[str, Any]: return {"dates": sorted(db.list_holidays(DB_PATH))} +@app.get("/api/schedule/versions") +def get_schedule_versions() -> dict[str, Any]: + return {"versions": db.get_schedule_versions(DB_PATH)} + + @app.get("/api/schedule/week") -def get_schedule_week(start: str | None = None) -> dict[str, Any]: +def get_schedule_week(start: str | None = None, version: int | None = None) -> dict[str, Any]: week_start = start or date.today().isoformat() try: datetime.strptime(week_start, "%Y-%m-%d") except ValueError as exc: raise HTTPException(status_code=400, detail="start must be YYYY-MM-DD") from exc + if version is not None and version <= 0: + raise HTTPException(status_code=400, detail="version must be a positive integer") week_start_date = datetime.strptime(week_start, "%Y-%m-%d").date() - rows = db.get_schedule_week(week_start, DB_PATH) - all_rows = db.get_schedule_rows_for_latest_version(DB_PATH) + selected_version = db.resolve_schedule_version(version, DB_PATH) + if version is not None and selected_version is None: + raise HTTPException(status_code=404, detail=f"Schedule version {version} was not found") + + rows = db.get_schedule_week(week_start, selected_version, DB_PATH) + + # Log completed tests on the current schedule + completed_count = sum(1 for row in rows if row.status == 'completed') + if completed_count > 0: + print(f"[api] /schedule/week: {completed_count} test(s) marked as completed on calendar") + for row in rows: + if row.status == 'completed': + print(f" - {row.test_id} on {row.device} (scheduled {row.scheduled_date})") + + all_rows = db.get_schedule_rows(selected_version, DB_PATH) completion_date = max((row.scheduled_date for row in all_rows), default=None) holiday_dates = db.list_holidays(DB_PATH) return { "start_date": week_start, + "schedule_version": selected_version, "items": [_serialize_schedule_row(row) for row in rows], "total_scheduled_tests": len(all_rows), "completion_date": completion_date, diff --git a/backend/db.py b/backend/db.py index 225ecf3..9b0a4a2 100644 --- a/backend/db.py +++ b/backend/db.py @@ -471,11 +471,69 @@ def create_schedule_version( return next_version -def get_schedule_week(start_date: str, db_path: str | Path = DB_PATH) -> list[ScheduleRow]: +def get_schedule_versions(db_path: str | Path = DB_PATH) -> list[dict[str, Any]]: with get_connection(db_path) as conn: - version_row = conn.execute("SELECT MAX(schedule_version) AS latest FROM schedules").fetchone() - latest = version_row["latest"] - if latest is None: + rows = conn.execute( + """ + SELECT + schedule_version, + MIN(created_at) AS created_at, + COUNT(*) AS entry_count + FROM schedules + GROUP BY schedule_version + ORDER BY schedule_version DESC + """ + ).fetchall() + + return [ + { + "schedule_version": int(row["schedule_version"]), + "created_at": row["created_at"], + "entry_count": int(row["entry_count"]), + } + for row in rows + ] + + +def resolve_schedule_version( + version: int | None = None, + db_path: str | Path = DB_PATH, +) -> int | None: + with get_connection(db_path) as conn: + if version is not None: + row = conn.execute( + "SELECT 1 FROM schedules WHERE schedule_version = ? LIMIT 1", + (version,), + ).fetchone() + return int(version) if row else None + + latest_row = conn.execute("SELECT MAX(schedule_version) AS latest FROM schedules").fetchone() + latest = latest_row["latest"] + return int(latest) if latest is not None else None + + +def get_schedule_week( + start_date: str, + version: int | None = None, + db_path: str | Path = DB_PATH, +) -> list[ScheduleRow]: + with get_connection(db_path) as conn: + if version is not None: + version_row = conn.execute( + "SELECT 1 AS exists_row FROM schedules WHERE schedule_version = ? LIMIT 1", + (version,), + ).fetchone() + if version_row is None: + return [] + selected_version = int(version) + else: + latest_row = conn.execute("SELECT MAX(schedule_version) AS latest FROM schedules").fetchone() + latest = latest_row["latest"] + if latest is None: + return [] + selected_version = int(latest) + + if selected_version is None: return [] rows = conn.execute( @@ -514,7 +572,7 @@ def get_schedule_week(start_date: str, db_path: str | Path = DB_PATH) -> list[Sc ORDER BY s.scheduled_date, s.shift_index, s.sequence_in_shift """, ( - latest, + selected_version, start_date, start_date, start_date, @@ -544,11 +602,27 @@ def get_schedule_week(start_date: str, db_path: str | Path = DB_PATH) -> list[Sc return result -def get_schedule_rows_for_latest_version(db_path: str | Path = DB_PATH) -> list[ScheduleRow]: +def get_schedule_rows( + version: int | None = None, + db_path: str | Path = DB_PATH, +) -> list[ScheduleRow]: with get_connection(db_path) as conn: - version_row = conn.execute("SELECT MAX(schedule_version) AS latest FROM schedules").fetchone() - latest = version_row["latest"] - if latest is None: + if version is not None: + version_row = conn.execute( + "SELECT 1 AS exists_row FROM schedules WHERE schedule_version = ? LIMIT 1", + (version,), + ).fetchone() + if version_row is None: + return [] + selected_version = int(version) + else: + latest_row = conn.execute("SELECT MAX(schedule_version) AS latest FROM schedules").fetchone() + latest = latest_row["latest"] + if latest is None: + return [] + selected_version = int(latest) + + if selected_version is None: return [] rows = conn.execute( @@ -570,7 +644,7 @@ def get_schedule_rows_for_latest_version(db_path: str | Path = DB_PATH) -> list[ WHERE s.schedule_version = ? ORDER BY s.scheduled_date, s.shift_index, s.sequence_in_shift """, - (latest,), + (selected_version,), ).fetchall() result: list[ScheduleRow] = [] @@ -593,21 +667,31 @@ def get_schedule_rows_for_latest_version(db_path: str | Path = DB_PATH) -> list[ return result -def mark_tests_completed(test_ids_with_device: list[tuple[str, str]], db_path: str | Path = DB_PATH) -> None: +def mark_tests_completed(test_ids_with_device: list[tuple[str, str]], db_path: str | Path = DB_PATH) -> int: if not test_ids_with_device: - return + return 0 with get_connection(db_path) as conn: - conn.executemany( - """ - UPDATE tests - SET status = 'completed', updated_at = CURRENT_TIMESTAMP - WHERE test_id = ? - AND device = ? - AND status != 'completed' - """, - test_ids_with_device, - ) + # executemany() doesn't properly return rowcount, so we track manually + total_updated = 0 + for test_id, device in test_ids_with_device: + cursor = conn.execute( + """ + UPDATE tests + SET status = 'completed', updated_at = CURRENT_TIMESTAMP + WHERE test_id = ? + AND device = ? + AND status != 'completed' + """, + (test_id, device), + ) + if cursor.rowcount > 0: + total_updated += cursor.rowcount + print(f"[db] marked {test_id} on {device} as completed") + else: + print(f"[db] {test_id} on {device}: no update (not found or already completed)") + + return total_updated def mark_overdue_as_rerun(db_path: str | Path = DB_PATH) -> int: diff --git a/backend/scanner.py b/backend/scanner.py index f12d39b..d7b3d06 100644 --- a/backend/scanner.py +++ b/backend/scanner.py @@ -283,6 +283,7 @@ def scan_results(results_dir_dut, results_dir_ref, smb_credentials=None): entry.name for entry in _iter_dir_entries(results_dir_ref, smb_credentials=smb_credentials) if entry.is_dir() + and not entry.name.startswith("obsolete") ] except OSError as exc: print(f"[scanner] Cannot read results dir: {exc}") @@ -310,7 +311,14 @@ def scan_results(results_dir_dut, results_dir_ref, smb_credentials=None): if unmatched_entries: print(f"[scanner] skipped {len(unmatched_entries)} result dir(s) with no recognizable test id") - mark_tests_completed(completed_batch) + if completed_batch: + print(f"[scanner] marking {len(completed_batch)} test(s) as completed:") + for test_id, device in completed_batch: + print(f" - {test_id} on {device}") + + updated_count = mark_tests_completed(completed_batch) + print(f"[scanner] {updated_count} test(s) actually updated in database") + newly_rerun = mark_overdue_as_rerun() if newly_rerun: print(f"[scanner] {newly_rerun} test(s) marked as rerun-required (scheduled but not completed)") diff --git a/docker-compose.yml b/docker-compose.yml index 9a09f86..7f98330 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,12 @@ services: context: ./backend dockerfile: Dockerfile container_name: scheduler-backend + environment: + HOST_BROWSE_ROOT: ${HOST_BROWSE_ROOT} + HOST_MOUNT_ROOT: /host + volumes: + - ${HOST_BROWSE_ROOT}:/host + - scheduler-db:/app expose: - "8000" restart: unless-stopped @@ -29,3 +35,6 @@ services: - backend - frontend restart: unless-stopped + +volumes: + scheduler-db: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9877bbc..eaa77c5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -170,6 +170,7 @@ export default function App() { const [startDateOverride, setStartDateOverride] = useState('') const [failedTests, setFailedTests] = useState([]) const [scheduleData, setScheduleData] = useState({}) + const [previousScheduleData, setPreviousScheduleData] = useState({}) const [scheduleWindows, setScheduleWindows] = useState([]) const [selectedWindowId, setSelectedWindowId] = useState(null) const [completionDate, setCompletionDate] = useState(null) @@ -202,13 +203,54 @@ export default function App() { // Fetch schedule for the given weekStart (Monday) const fetchSchedule = useCallback(async (start) => { - try { - const data = await api.getScheduleWeek(toKey(start)) + const key = toKey(start) + + const loadLegacyWeek = async () => { + const data = await api.getScheduleWeek(key) setScheduleData(groupScheduleItems(data.items)) + setPreviousScheduleData({}) setScheduleWindows(data.windows ?? []) setCompletionDate(data.completion_date ?? null) + } + + try { + let versionsData = null + try { + versionsData = await api.getScheduleVersions() + } catch (_versionErr) { + // Fallback for older backend builds that do not expose /schedule/versions yet. + await loadLegacyWeek() + return + } + + const versions = versionsData.versions ?? [] + + const latestVersion = versions[0]?.schedule_version ?? null + const previousVersion = versions[1]?.schedule_version ?? null + + if (latestVersion === null) { + await loadLegacyWeek() + return + } + + const latestData = await api.getScheduleWeek(key, latestVersion) + setScheduleData(groupScheduleItems(latestData.items)) + setScheduleWindows(latestData.windows ?? []) + setCompletionDate(latestData.completion_date ?? null) + + if (previousVersion !== null) { + const previousData = await api.getScheduleWeek(key, previousVersion) + setPreviousScheduleData(groupScheduleItems(previousData.items)) + } else { + setPreviousScheduleData({}) + } } catch (e) { console.error('Failed to fetch schedule:', e) + try { + await loadLegacyWeek() + } catch (fallbackErr) { + console.error('Fallback schedule fetch also failed:', fallbackErr) + } } }, []) @@ -371,6 +413,7 @@ export default function App() {
request('POST', '/schedule/compile', opts), - getScheduleWeek: (start) => request('GET', `/schedule/week?start=${start}`), + getScheduleVersions: () => request('GET', '/schedule/versions'), + getScheduleWeek: (start, version = null) => { + const params = new URLSearchParams({ start }) + if (version !== null && version !== undefined) { + params.set('version', String(version)) + } + return request('GET', `/schedule/week?${params.toString()}`) + }, getRerunTests: () => request('GET', '/tests/rerun'), } diff --git a/frontend/src/components/Calendar.jsx b/frontend/src/components/Calendar.jsx index 5ec8f3b..bc53d4b 100644 --- a/frontend/src/components/Calendar.jsx +++ b/frontend/src/components/Calendar.jsx @@ -23,6 +23,14 @@ function toDateKey(date) { return date.toISOString().slice(0, 10) } +function emptyDayShifts() { + return { shift1: [], shift2: [], shift3: [] } +} + +function dayHasTests(shifts = {}) { + return (shifts.shift1?.length ?? 0) > 0 || (shifts.shift2?.length ?? 0) > 0 || (shifts.shift3?.length ?? 0) > 0 +} + function getMondayOfWeek(date) { const d = new Date(date) const day = d.getDay() @@ -51,6 +59,7 @@ function getNextTestWindow() { // scheduleData: { "YYYY-MM-DD": { shift1: [...], shift2: [...], shift3: [...] }, ... } export default function Calendar({ scheduleData = {}, + previousScheduleData = {}, daytimeDateKey = null, weekStart, onWeekChange, @@ -75,6 +84,34 @@ export default function Calendar({ // 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)] + const daySourceByKey = useMemo(() => { + const out = {} + for (const date of orderedDays) { + const key = toDateKey(date) + const latestDay = scheduleData[key] ?? emptyDayShifts() + const previousDay = previousScheduleData[key] ?? emptyDayShifts() + + if (dayHasTests(latestDay)) { + out[key] = 'current' + } else if (dayHasTests(previousDay)) { + out[key] = 'previous' + } else { + out[key] = 'current' + } + } + return out + }, [orderedDays, scheduleData, previousScheduleData]) + const mergedScheduleData = useMemo(() => { + const out = {} + for (const date of orderedDays) { + const key = toDateKey(date) + const source = daySourceByKey[key] + out[key] = source === 'previous' + ? (previousScheduleData[key] ?? emptyDayShifts()) + : (scheduleData[key] ?? emptyDayShifts()) + } + return out + }, [orderedDays, daySourceByKey, scheduleData, previousScheduleData]) const shiftMinRows = useMemo(() => { const minima = { shift2: DEFAULT_SHIFT_MIN_ROWS, @@ -84,9 +121,9 @@ export default function Calendar({ for (const date of orderedDays) { const key = toDateKey(date) - const shifts = scheduleData[key] ?? { shift1: [], shift2: [], shift3: [] } + const shifts = mergedScheduleData[key] ?? emptyDayShifts() const nextKey = toDateKey(addDays(date, 1)) - const nextDayShift1 = scheduleData[nextKey]?.shift1 ?? [] + const nextDayShift1 = mergedScheduleData[nextKey]?.shift1 ?? [] minima.shift2 = Math.max(minima.shift2, shifts.shift2.length || DEFAULT_SHIFT_MIN_ROWS) minima.shift3 = Math.max(minima.shift3, shifts.shift3.length || DEFAULT_SHIFT_MIN_ROWS) @@ -94,7 +131,7 @@ export default function Calendar({ } return minima - }, [orderedDays, scheduleData]) + }, [orderedDays, mergedScheduleData]) const slotHeights = useMemo(() => { const calcSlotHeight = (rows) => { const safeRows = Math.max(DEFAULT_SHIFT_MIN_ROWS, rows || DEFAULT_SHIFT_MIN_ROWS) @@ -111,7 +148,7 @@ export default function Calendar({ const totalShiftHeightPx = slotHeights.shift2 + slotHeights.shift3 + slotHeights.nextDayShift1 const deviceLegendItems = Array.from( new Set( - Object.values(scheduleData) + Object.values(mergedScheduleData) .flatMap((day) => [ ...(day?.shift1 ?? []), ...(day?.shift2 ?? []), @@ -200,13 +237,15 @@ export default function Calendar({
{orderedDays.map((date) => { const key = toDateKey(date) - const shifts = scheduleData[key] ?? { shift1: [], shift2: [], shift3: [] } + const shifts = mergedScheduleData[key] ?? emptyDayShifts() const isToday = date.getTime() === today.getTime() const isWeekend = date.getDay() === 0 || date.getDay() === 6 // Get next day's shift1 for display const nextDate = addDays(date, 1) const nextKey = toDateKey(nextDate) - const nextDayShift1 = scheduleData[nextKey]?.shift1 ?? [] + const nextDayShift1 = mergedScheduleData[nextKey]?.shift1 ?? [] + const shiftVariant = daySourceByKey[key] === 'previous' ? 'previous' : 'current' + const nextDayShift1Variant = daySourceByKey[nextKey] === 'previous' ? 'previous' : 'current' // Which shifts on this date are part of the active test window? const activeShifts = new Set() if (key === activeWindow.shift3Date) activeShifts.add(3) @@ -222,6 +261,11 @@ export default function Calendar({ shifts={shifts} nextDayShift1={nextDayShift1} nextDayShift1Active={nextDayShift1Active} + shiftVariants={{ + shift2: shiftVariant, + shift3: shiftVariant, + nextDayShift1: nextDayShift1Variant, + }} shiftMinRows={shiftMinRows} slotHeights={slotHeights} showShift2={isWeekend || (daytimeDateKey !== null && key === daytimeDateKey)} @@ -238,6 +282,7 @@ export default function Calendar({ Pending Completed Rerun Required + Previous Schedule (faded) {deviceLegendItems.map((device) => ( diff --git a/frontend/src/components/DayColumn.jsx b/frontend/src/components/DayColumn.jsx index e360dee..8432bd3 100644 --- a/frontend/src/components/DayColumn.jsx +++ b/frontend/src/components/DayColumn.jsx @@ -9,6 +9,7 @@ export default function DayColumn({ shifts = {}, nextDayShift1 = [], nextDayShift1Active = false, + shiftVariants = { shift2: 'current', shift3: 'current', nextDayShift1: 'current' }, showShift2 = false, shiftMinRows = { shift2: 1, shift3: 1, nextDayShift1: 1 }, slotHeights = { shift2: 0, shift3: 0, nextDayShift1: 0 }, @@ -52,6 +53,7 @@ export default function DayColumn({ tests={shifts.shift2} visible={true} active={activeShifts.has(2)} + variant={shiftVariants.shift2} windowDetails={getWindowForShift(2)} minContentRows={shiftMinRows.shift2} slotHeightPx={slotHeights.shift2} @@ -61,6 +63,7 @@ export default function DayColumn({ tests={shifts.shift3} visible={true} active={activeShifts.has(3)} + variant={shiftVariants.shift3} windowDetails={getWindowForShift(3)} minContentRows={shiftMinRows.shift3} slotHeightPx={slotHeights.shift3} @@ -70,6 +73,7 @@ export default function DayColumn({ tests={nextDayShift1} visible={true} active={nextDayShift1Active} + variant={shiftVariants.nextDayShift1} windowDetails={getNextDayShift1Window()} minContentRows={shiftMinRows.nextDayShift1} slotHeightPx={slotHeights.nextDayShift1} diff --git a/frontend/src/components/ShiftSlot.jsx b/frontend/src/components/ShiftSlot.jsx index 7ece73e..03bd9a4 100644 --- a/frontend/src/components/ShiftSlot.jsx +++ b/frontend/src/components/ShiftSlot.jsx @@ -7,6 +7,7 @@ export default function ShiftSlot({ tests = [], visible = true, active = false, + variant = 'current', windowDetails = null, minContentRows = 3, slotHeightPx = null, @@ -15,6 +16,7 @@ export default function ShiftSlot({ if (!visible) return null const clickable = Boolean(windowDetails && onWindowSelect) + const isPrevious = variant === 'previous' 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 @@ -35,7 +37,7 @@ export default function ShiftSlot({ style={{ minHeight: `${outerMinHeightPx}px` }} className={`border-t pt-1 pb-1.5 transition-colors ${ 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' : ''}`} + } ${isPrevious ? 'bg-gray-700/15' : ''} ${clickable ? 'cursor-pointer hover:bg-gray-700/25 focus:outline-none focus:ring-1 focus:ring-cyan-400/70' : ''}`} >
)) )} diff --git a/frontend/src/components/TestCard.jsx b/frontend/src/components/TestCard.jsx index 29727bf..0458883 100644 --- a/frontend/src/components/TestCard.jsx +++ b/frontend/src/components/TestCard.jsx @@ -46,7 +46,7 @@ function formatConfig(config) { return formattedEntries.length > 0 ? formattedEntries.join('; ') : '—' } -export default function TestCard({ test }) { +export default function TestCard({ test, isPrevious = false }) { const style = STATUS_STYLES[test.status] ?? STATUS_STYLES.pending const deviceAccentClass = getDeviceAccentClass(test.device) const cardRef = useRef(null) @@ -90,7 +90,7 @@ export default function TestCard({ test }) { return (