show completion in frontend
This commit is contained in:
@@ -6,3 +6,5 @@
|
|||||||
IMPLEMENTATIONPLAN.md
|
IMPLEMENTATIONPLAN.md
|
||||||
scheduler.db
|
scheduler.db
|
||||||
scheduler.db*
|
scheduler.db*
|
||||||
|
TARGET_TEST_DIR_STRUCTURE.md
|
||||||
|
.env
|
||||||
+24
-3
@@ -452,21 +452,42 @@ def get_holidays() -> dict[str, Any]:
|
|||||||
return {"dates": sorted(db.list_holidays(DB_PATH))}
|
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")
|
@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()
|
week_start = start or date.today().isoformat()
|
||||||
try:
|
try:
|
||||||
datetime.strptime(week_start, "%Y-%m-%d")
|
datetime.strptime(week_start, "%Y-%m-%d")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail="start must be YYYY-MM-DD") from 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()
|
week_start_date = datetime.strptime(week_start, "%Y-%m-%d").date()
|
||||||
rows = db.get_schedule_week(week_start, DB_PATH)
|
selected_version = db.resolve_schedule_version(version, DB_PATH)
|
||||||
all_rows = db.get_schedule_rows_for_latest_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)
|
completion_date = max((row.scheduled_date for row in all_rows), default=None)
|
||||||
holiday_dates = db.list_holidays(DB_PATH)
|
holiday_dates = db.list_holidays(DB_PATH)
|
||||||
return {
|
return {
|
||||||
"start_date": week_start,
|
"start_date": week_start,
|
||||||
|
"schedule_version": selected_version,
|
||||||
"items": [_serialize_schedule_row(row) for row in rows],
|
"items": [_serialize_schedule_row(row) for row in rows],
|
||||||
"total_scheduled_tests": len(all_rows),
|
"total_scheduled_tests": len(all_rows),
|
||||||
"completion_date": completion_date,
|
"completion_date": completion_date,
|
||||||
|
|||||||
+96
-12
@@ -471,12 +471,70 @@ def create_schedule_version(
|
|||||||
return next_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:
|
with get_connection(db_path) as conn:
|
||||||
version_row = conn.execute("SELECT MAX(schedule_version) AS latest FROM schedules").fetchone()
|
rows = conn.execute(
|
||||||
latest = version_row["latest"]
|
"""
|
||||||
|
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:
|
if latest is None:
|
||||||
return []
|
return []
|
||||||
|
selected_version = int(latest)
|
||||||
|
|
||||||
|
if selected_version is None:
|
||||||
|
return []
|
||||||
|
|
||||||
rows = conn.execute(
|
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
|
ORDER BY s.scheduled_date, s.shift_index, s.sequence_in_shift
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
latest,
|
selected_version,
|
||||||
start_date,
|
start_date,
|
||||||
start_date,
|
start_date,
|
||||||
start_date,
|
start_date,
|
||||||
@@ -544,12 +602,28 @@ def get_schedule_week(start_date: str, db_path: str | Path = DB_PATH) -> list[Sc
|
|||||||
return result
|
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:
|
with get_connection(db_path) as conn:
|
||||||
version_row = conn.execute("SELECT MAX(schedule_version) AS latest FROM schedules").fetchone()
|
if version is not None:
|
||||||
latest = version_row["latest"]
|
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:
|
if latest is None:
|
||||||
return []
|
return []
|
||||||
|
selected_version = int(latest)
|
||||||
|
|
||||||
|
if selected_version is None:
|
||||||
|
return []
|
||||||
|
|
||||||
rows = conn.execute(
|
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 = ?
|
WHERE s.schedule_version = ?
|
||||||
ORDER BY s.scheduled_date, s.shift_index, s.sequence_in_shift
|
ORDER BY s.scheduled_date, s.shift_index, s.sequence_in_shift
|
||||||
""",
|
""",
|
||||||
(latest,),
|
(selected_version,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
result: list[ScheduleRow] = []
|
result: list[ScheduleRow] = []
|
||||||
@@ -593,12 +667,15 @@ def get_schedule_rows_for_latest_version(db_path: str | Path = DB_PATH) -> list[
|
|||||||
|
|
||||||
return result
|
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:
|
if not test_ids_with_device:
|
||||||
return
|
return 0
|
||||||
|
|
||||||
with get_connection(db_path) as conn:
|
with get_connection(db_path) as conn:
|
||||||
conn.executemany(
|
# 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
|
UPDATE tests
|
||||||
SET status = 'completed', updated_at = CURRENT_TIMESTAMP
|
SET status = 'completed', updated_at = CURRENT_TIMESTAMP
|
||||||
@@ -606,8 +683,15 @@ def mark_tests_completed(test_ids_with_device: list[tuple[str, str]], db_path: s
|
|||||||
AND device = ?
|
AND device = ?
|
||||||
AND status != 'completed'
|
AND status != 'completed'
|
||||||
""",
|
""",
|
||||||
test_ids_with_device,
|
(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:
|
def mark_overdue_as_rerun(db_path: str | Path = DB_PATH) -> int:
|
||||||
|
|||||||
+9
-1
@@ -283,6 +283,7 @@ def scan_results(results_dir_dut, results_dir_ref, smb_credentials=None):
|
|||||||
entry.name
|
entry.name
|
||||||
for entry in _iter_dir_entries(results_dir_ref, smb_credentials=smb_credentials)
|
for entry in _iter_dir_entries(results_dir_ref, smb_credentials=smb_credentials)
|
||||||
if entry.is_dir()
|
if entry.is_dir()
|
||||||
|
and not entry.name.startswith("obsolete")
|
||||||
]
|
]
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
print(f"[scanner] Cannot read results dir: {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:
|
if unmatched_entries:
|
||||||
print(f"[scanner] skipped {len(unmatched_entries)} result dir(s) with no recognizable test id")
|
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()
|
newly_rerun = mark_overdue_as_rerun()
|
||||||
if newly_rerun:
|
if newly_rerun:
|
||||||
print(f"[scanner] {newly_rerun} test(s) marked as rerun-required (scheduled but not completed)")
|
print(f"[scanner] {newly_rerun} test(s) marked as rerun-required (scheduled but not completed)")
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: scheduler-backend
|
container_name: scheduler-backend
|
||||||
|
environment:
|
||||||
|
HOST_BROWSE_ROOT: ${HOST_BROWSE_ROOT}
|
||||||
|
HOST_MOUNT_ROOT: /host
|
||||||
|
volumes:
|
||||||
|
- ${HOST_BROWSE_ROOT}:/host
|
||||||
|
- scheduler-db:/app
|
||||||
expose:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -29,3 +35,6 @@ services:
|
|||||||
- backend
|
- backend
|
||||||
- frontend
|
- frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
scheduler-db:
|
||||||
|
|||||||
+45
-2
@@ -170,6 +170,7 @@ export default function App() {
|
|||||||
const [startDateOverride, setStartDateOverride] = useState('')
|
const [startDateOverride, setStartDateOverride] = useState('')
|
||||||
const [failedTests, setFailedTests] = useState([])
|
const [failedTests, setFailedTests] = useState([])
|
||||||
const [scheduleData, setScheduleData] = useState({})
|
const [scheduleData, setScheduleData] = useState({})
|
||||||
|
const [previousScheduleData, setPreviousScheduleData] = useState({})
|
||||||
const [scheduleWindows, setScheduleWindows] = useState([])
|
const [scheduleWindows, setScheduleWindows] = useState([])
|
||||||
const [selectedWindowId, setSelectedWindowId] = useState(null)
|
const [selectedWindowId, setSelectedWindowId] = useState(null)
|
||||||
const [completionDate, setCompletionDate] = useState(null)
|
const [completionDate, setCompletionDate] = useState(null)
|
||||||
@@ -202,13 +203,54 @@ export default function App() {
|
|||||||
|
|
||||||
// Fetch schedule for the given weekStart (Monday)
|
// Fetch schedule for the given weekStart (Monday)
|
||||||
const fetchSchedule = useCallback(async (start) => {
|
const fetchSchedule = useCallback(async (start) => {
|
||||||
try {
|
const key = toKey(start)
|
||||||
const data = await api.getScheduleWeek(toKey(start))
|
|
||||||
|
const loadLegacyWeek = async () => {
|
||||||
|
const data = await api.getScheduleWeek(key)
|
||||||
setScheduleData(groupScheduleItems(data.items))
|
setScheduleData(groupScheduleItems(data.items))
|
||||||
|
setPreviousScheduleData({})
|
||||||
setScheduleWindows(data.windows ?? [])
|
setScheduleWindows(data.windows ?? [])
|
||||||
setCompletionDate(data.completion_date ?? null)
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch schedule:', 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() {
|
|||||||
<div className="flex-1 min-w-0 flex flex-col">
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
<Calendar
|
<Calendar
|
||||||
scheduleData={scheduleData}
|
scheduleData={scheduleData}
|
||||||
|
previousScheduleData={previousScheduleData}
|
||||||
daytimeDateKey={null}
|
daytimeDateKey={null}
|
||||||
weekStart={weekStart}
|
weekStart={weekStart}
|
||||||
onWeekChange={setWeekStart}
|
onWeekChange={setWeekStart}
|
||||||
|
|||||||
+8
-1
@@ -30,7 +30,14 @@ export const api = {
|
|||||||
|
|
||||||
// Schedule
|
// Schedule
|
||||||
compileSchedule: (opts) => request('POST', '/schedule/compile', opts),
|
compileSchedule: (opts) => 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'),
|
getRerunTests: () => request('GET', '/tests/rerun'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ function toDateKey(date) {
|
|||||||
return date.toISOString().slice(0, 10)
|
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) {
|
function getMondayOfWeek(date) {
|
||||||
const d = new Date(date)
|
const d = new Date(date)
|
||||||
const day = d.getDay()
|
const day = d.getDay()
|
||||||
@@ -51,6 +59,7 @@ function getNextTestWindow() {
|
|||||||
// scheduleData: { "YYYY-MM-DD": { shift1: [...], shift2: [...], shift3: [...] }, ... }
|
// scheduleData: { "YYYY-MM-DD": { shift1: [...], shift2: [...], shift3: [...] }, ... }
|
||||||
export default function Calendar({
|
export default function Calendar({
|
||||||
scheduleData = {},
|
scheduleData = {},
|
||||||
|
previousScheduleData = {},
|
||||||
daytimeDateKey = null,
|
daytimeDateKey = null,
|
||||||
weekStart,
|
weekStart,
|
||||||
onWeekChange,
|
onWeekChange,
|
||||||
@@ -75,6 +84,34 @@ 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 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 shiftMinRows = useMemo(() => {
|
||||||
const minima = {
|
const minima = {
|
||||||
shift2: DEFAULT_SHIFT_MIN_ROWS,
|
shift2: DEFAULT_SHIFT_MIN_ROWS,
|
||||||
@@ -84,9 +121,9 @@ export default function Calendar({
|
|||||||
|
|
||||||
for (const date of orderedDays) {
|
for (const date of orderedDays) {
|
||||||
const key = toDateKey(date)
|
const key = toDateKey(date)
|
||||||
const shifts = scheduleData[key] ?? { shift1: [], shift2: [], shift3: [] }
|
const shifts = mergedScheduleData[key] ?? emptyDayShifts()
|
||||||
const nextKey = toDateKey(addDays(date, 1))
|
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.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)
|
minima.shift3 = Math.max(minima.shift3, shifts.shift3.length || DEFAULT_SHIFT_MIN_ROWS)
|
||||||
@@ -94,7 +131,7 @@ export default function Calendar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return minima
|
return minima
|
||||||
}, [orderedDays, scheduleData])
|
}, [orderedDays, mergedScheduleData])
|
||||||
const slotHeights = useMemo(() => {
|
const slotHeights = useMemo(() => {
|
||||||
const calcSlotHeight = (rows) => {
|
const calcSlotHeight = (rows) => {
|
||||||
const safeRows = Math.max(DEFAULT_SHIFT_MIN_ROWS, rows || DEFAULT_SHIFT_MIN_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 totalShiftHeightPx = slotHeights.shift2 + slotHeights.shift3 + slotHeights.nextDayShift1
|
||||||
const deviceLegendItems = Array.from(
|
const deviceLegendItems = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
Object.values(scheduleData)
|
Object.values(mergedScheduleData)
|
||||||
.flatMap((day) => [
|
.flatMap((day) => [
|
||||||
...(day?.shift1 ?? []),
|
...(day?.shift1 ?? []),
|
||||||
...(day?.shift2 ?? []),
|
...(day?.shift2 ?? []),
|
||||||
@@ -200,13 +237,15 @@ export default function Calendar({
|
|||||||
<div className="flex gap-1.5 flex-1 overflow-x-auto">
|
<div className="flex gap-1.5 flex-1 overflow-x-auto">
|
||||||
{orderedDays.map((date) => {
|
{orderedDays.map((date) => {
|
||||||
const key = toDateKey(date)
|
const key = toDateKey(date)
|
||||||
const shifts = scheduleData[key] ?? { shift1: [], shift2: [], shift3: [] }
|
const shifts = mergedScheduleData[key] ?? emptyDayShifts()
|
||||||
const isToday = date.getTime() === today.getTime()
|
const isToday = date.getTime() === today.getTime()
|
||||||
const isWeekend = date.getDay() === 0 || date.getDay() === 6
|
const isWeekend = date.getDay() === 0 || date.getDay() === 6
|
||||||
// Get next day's shift1 for display
|
// Get next day's shift1 for display
|
||||||
const nextDate = addDays(date, 1)
|
const nextDate = addDays(date, 1)
|
||||||
const nextKey = toDateKey(nextDate)
|
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?
|
// Which shifts on this date are part of the active test window?
|
||||||
const activeShifts = new Set()
|
const activeShifts = new Set()
|
||||||
if (key === activeWindow.shift3Date) activeShifts.add(3)
|
if (key === activeWindow.shift3Date) activeShifts.add(3)
|
||||||
@@ -222,6 +261,11 @@ export default function Calendar({
|
|||||||
shifts={shifts}
|
shifts={shifts}
|
||||||
nextDayShift1={nextDayShift1}
|
nextDayShift1={nextDayShift1}
|
||||||
nextDayShift1Active={nextDayShift1Active}
|
nextDayShift1Active={nextDayShift1Active}
|
||||||
|
shiftVariants={{
|
||||||
|
shift2: shiftVariant,
|
||||||
|
shift3: shiftVariant,
|
||||||
|
nextDayShift1: nextDayShift1Variant,
|
||||||
|
}}
|
||||||
shiftMinRows={shiftMinRows}
|
shiftMinRows={shiftMinRows}
|
||||||
slotHeights={slotHeights}
|
slotHeights={slotHeights}
|
||||||
showShift2={isWeekend || (daytimeDateKey !== null && key === daytimeDateKey)}
|
showShift2={isWeekend || (daytimeDateKey !== null && key === daytimeDateKey)}
|
||||||
@@ -238,6 +282,7 @@ export default function Calendar({
|
|||||||
<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-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-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" />Rerun Required</span>
|
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-red-700 inline-block" />Rerun Required</span>
|
||||||
|
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-gray-400/70 inline-block" />Previous Schedule (faded)</span>
|
||||||
{deviceLegendItems.map((device) => (
|
{deviceLegendItems.map((device) => (
|
||||||
<span key={device} className="flex items-center gap-1.5">
|
<span key={device} className="flex items-center gap-1.5">
|
||||||
<span className={`w-2.5 h-2.5 rounded-sm inline-block ${getDeviceAccentClass(device)}`} />
|
<span className={`w-2.5 h-2.5 rounded-sm inline-block ${getDeviceAccentClass(device)}`} />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export default function DayColumn({
|
|||||||
shifts = {},
|
shifts = {},
|
||||||
nextDayShift1 = [],
|
nextDayShift1 = [],
|
||||||
nextDayShift1Active = false,
|
nextDayShift1Active = false,
|
||||||
|
shiftVariants = { shift2: 'current', shift3: 'current', nextDayShift1: 'current' },
|
||||||
showShift2 = false,
|
showShift2 = false,
|
||||||
shiftMinRows = { shift2: 1, shift3: 1, nextDayShift1: 1 },
|
shiftMinRows = { shift2: 1, shift3: 1, nextDayShift1: 1 },
|
||||||
slotHeights = { shift2: 0, shift3: 0, nextDayShift1: 0 },
|
slotHeights = { shift2: 0, shift3: 0, nextDayShift1: 0 },
|
||||||
@@ -52,6 +53,7 @@ export default function DayColumn({
|
|||||||
tests={shifts.shift2}
|
tests={shifts.shift2}
|
||||||
visible={true}
|
visible={true}
|
||||||
active={activeShifts.has(2)}
|
active={activeShifts.has(2)}
|
||||||
|
variant={shiftVariants.shift2}
|
||||||
windowDetails={getWindowForShift(2)}
|
windowDetails={getWindowForShift(2)}
|
||||||
minContentRows={shiftMinRows.shift2}
|
minContentRows={shiftMinRows.shift2}
|
||||||
slotHeightPx={slotHeights.shift2}
|
slotHeightPx={slotHeights.shift2}
|
||||||
@@ -61,6 +63,7 @@ export default function DayColumn({
|
|||||||
tests={shifts.shift3}
|
tests={shifts.shift3}
|
||||||
visible={true}
|
visible={true}
|
||||||
active={activeShifts.has(3)}
|
active={activeShifts.has(3)}
|
||||||
|
variant={shiftVariants.shift3}
|
||||||
windowDetails={getWindowForShift(3)}
|
windowDetails={getWindowForShift(3)}
|
||||||
minContentRows={shiftMinRows.shift3}
|
minContentRows={shiftMinRows.shift3}
|
||||||
slotHeightPx={slotHeights.shift3}
|
slotHeightPx={slotHeights.shift3}
|
||||||
@@ -70,6 +73,7 @@ export default function DayColumn({
|
|||||||
tests={nextDayShift1}
|
tests={nextDayShift1}
|
||||||
visible={true}
|
visible={true}
|
||||||
active={nextDayShift1Active}
|
active={nextDayShift1Active}
|
||||||
|
variant={shiftVariants.nextDayShift1}
|
||||||
windowDetails={getNextDayShift1Window()}
|
windowDetails={getNextDayShift1Window()}
|
||||||
minContentRows={shiftMinRows.nextDayShift1}
|
minContentRows={shiftMinRows.nextDayShift1}
|
||||||
slotHeightPx={slotHeights.nextDayShift1}
|
slotHeightPx={slotHeights.nextDayShift1}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export default function ShiftSlot({
|
|||||||
tests = [],
|
tests = [],
|
||||||
visible = true,
|
visible = true,
|
||||||
active = false,
|
active = false,
|
||||||
|
variant = 'current',
|
||||||
windowDetails = null,
|
windowDetails = null,
|
||||||
minContentRows = 3,
|
minContentRows = 3,
|
||||||
slotHeightPx = null,
|
slotHeightPx = null,
|
||||||
@@ -15,6 +16,7 @@ export default function ShiftSlot({
|
|||||||
if (!visible) return null
|
if (!visible) return null
|
||||||
|
|
||||||
const clickable = Boolean(windowDetails && onWindowSelect)
|
const clickable = Boolean(windowDetails && onWindowSelect)
|
||||||
|
const isPrevious = variant === 'previous'
|
||||||
const targetRows = Math.max(1, minContentRows)
|
const targetRows = Math.max(1, minContentRows)
|
||||||
const minContentHeightPx = (targetRows * TEST_CARD_ROW_HEIGHT_PX) + ((targetRows - 1) * TEST_CARD_ROW_GAP_PX)
|
const minContentHeightPx = (targetRows * TEST_CARD_ROW_HEIGHT_PX) + ((targetRows - 1) * TEST_CARD_ROW_GAP_PX)
|
||||||
const outerMinHeightPx = Number.isFinite(slotHeightPx) && slotHeightPx > 0
|
const outerMinHeightPx = Number.isFinite(slotHeightPx) && slotHeightPx > 0
|
||||||
@@ -35,7 +37,7 @@ export default function ShiftSlot({
|
|||||||
style={{ minHeight: `${outerMinHeightPx}px` }}
|
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' : ''}`}
|
} ${isPrevious ? 'bg-gray-700/15' : ''} ${clickable ? 'cursor-pointer hover:bg-gray-700/25 focus:outline-none focus:ring-1 focus:ring-cyan-400/70' : ''}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex flex-col gap-0.5 px-1 min-h-4"
|
className="flex flex-col gap-0.5 px-1 min-h-4"
|
||||||
@@ -46,6 +48,7 @@ export default function ShiftSlot({
|
|||||||
<TestCard
|
<TestCard
|
||||||
key={`${test.test_id}-${test.device}-${test.scheduled_date ?? 'na'}-${test.shift_index ?? 'na'}-${test.sequence_in_shift ?? index}`}
|
key={`${test.test_id}-${test.device}-${test.scheduled_date ?? 'na'}-${test.shift_index ?? 'na'}-${test.sequence_in_shift ?? index}`}
|
||||||
test={test}
|
test={test}
|
||||||
|
isPrevious={isPrevious}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function formatConfig(config) {
|
|||||||
return formattedEntries.length > 0 ? formattedEntries.join('; ') : '—'
|
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 style = STATUS_STYLES[test.status] ?? STATUS_STYLES.pending
|
||||||
const deviceAccentClass = getDeviceAccentClass(test.device)
|
const deviceAccentClass = getDeviceAccentClass(test.device)
|
||||||
const cardRef = useRef(null)
|
const cardRef = useRef(null)
|
||||||
@@ -90,7 +90,7 @@ export default function TestCard({ test }) {
|
|||||||
return (
|
return (
|
||||||
<div ref={cardRef} className="relative">
|
<div ref={cardRef} className="relative">
|
||||||
<div
|
<div
|
||||||
className={`relative px-1.5 py-0.5 pl-3 rounded border text-xs font-mono truncate cursor-default select-none ${style}`}
|
className={`relative px-1.5 py-0.5 pl-3 rounded border text-xs font-mono truncate cursor-default select-none ${style} ${isPrevious ? 'opacity-60' : ''}`}
|
||||||
style={{ maxWidth: '100%' }}
|
style={{ maxWidth: '100%' }}
|
||||||
>
|
>
|
||||||
<span className={`absolute inset-y-0 left-0 w-1 rounded-l ${deviceAccentClass}`} aria-hidden="true" />
|
<span className={`absolute inset-y-0 left-0 w-1 rounded-l ${deviceAccentClass}`} aria-hidden="true" />
|
||||||
|
|||||||
Reference in New Issue
Block a user