diff --git a/backend/app.py b/backend/app.py index 0e1b162..426554b 100644 --- a/backend/app.py +++ b/backend/app.py @@ -406,6 +406,7 @@ def compile_schedule_endpoint(request: CompileScheduleRequest) -> dict[str, Any] top_priority_pairs: set[tuple[str, str]] = set() dut_top_priority_ids = {item.strip() for item in request.top_priority_tests_dut if item.strip()} ref_top_priority_ids = {item.strip() for item in request.top_priority_tests_ref if item.strip()} + status_lookup = {(test.test_id, test.device): test.status for test in stored_tests} # Backward compatibility: if only legacy top_priority_tests was sent, # keep previous behavior by applying IDs to whichever device has that test. @@ -445,7 +446,17 @@ def compile_schedule_endpoint(request: CompileScheduleRequest) -> dict[str, Any] raise HTTPException(status_code=409, detail=schedule_error) version = db.create_schedule_version( - [(e.test_id, e.device, e.scheduled_date, e.shift_index, e.sequence_in_shift) for e in entries], + [ + ( + e.test_id, + e.device, + e.scheduled_date, + e.shift_index, + e.sequence_in_shift, + status_lookup.get((e.test_id, e.device), "pending"), + ) + for e in entries + ], DB_PATH, ) print(f"Schedule version {version} created with {len(entries)} entries, completion date: {completion_date}") @@ -506,10 +517,17 @@ def get_schedule_week(start: str | None = None, version: int | None = None) -> d all_rows = db.get_schedule_rows(selected_version, DB_PATH) completion_date = max((row.scheduled_date for row in all_rows), default=None) + start_shift = min( + ((row.scheduled_date, row.shift_index) for row in all_rows), + default=(None, None), + ) + schedule_start_date, schedule_start_shift_index = start_shift holiday_dates = db.list_holidays(DB_PATH) return { "start_date": week_start, "schedule_version": selected_version, + "schedule_start_date": schedule_start_date, + "schedule_start_shift_index": schedule_start_shift_index, "items": [_serialize_schedule_row(row) for row in rows], "total_scheduled_tests": len(all_rows), "completion_date": completion_date, diff --git a/backend/data/One_Week_Test_Plan.csv b/backend/data/One_Week_Test_Plan_For_Mia.csv similarity index 85% rename from backend/data/One_Week_Test_Plan.csv rename to backend/data/One_Week_Test_Plan_For_Mia.csv index 11f53db..02a13e3 100644 --- a/backend/data/One_Week_Test_Plan.csv +++ b/backend/data/One_Week_Test_Plan_For_Mia.csv @@ -12,9 +12,11 @@ P1,5,No,No,R3,P2PRXBE008,5G,LPI,T1B,161,80,-67,UL,STA63,,,,,,,,,,,,,85,P2P Only, P1,5,No,No,R3,P2PRXBE009,5G,LPI,T1C,161,80,-45,UL,STA63,,,,,,,,,,,,,85,P2P Only,, P2,6,Yes,Yes,R3,COERXBE003,5G,LPI,T1F,161,80,-76,UL,STA63,T2A,5,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4,144,COE Test Case,COE test case. No plan to run, P2,7,Yes,Yes,R3,COERXBE004,5G,LPI,T1F,161,80,-76,UL,STA63,T2A,133,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4,144,COE Test Case,COE test case. No plan to run, +P2,9,Yes,Yes,R3,COERXBE006,5G,LPI,T1F,161,80,-76,UL,STA63,T2A,133,320,-79,DL,STA64,T3E,6,20,-70,DL,STA4,0,COE Test Case,320BW does not support on device,320BW does not support on device P1,10,No,Yes,R3,P2PRXBE010,5G,LPI,T1F,100,160,-76,UL,STA63,,,,,,,,,,,,,85,COE Test Case,, P1,10,No,No,R3,P2PRXBE011,5G,LPI,T1B,100,160,-67,UL,STA63,,,,,,,,,,,,,85,P2P Only,, P1,10,No,No,R3,P2PRXBE012,5G,LPI,T1C,100,160,-45,UL,STA63,,,,,,,,,,,,,85,P2P Only,, +P2,12,Yes,Yes,R3,COERXBE008,5G,LPI,T1F,100,160,-76,UL,STA63,T2A,133,320,-79,DL,STA64,T3E,6,20,-70,DL,STA4,0,COE Test Case,320BW does not support on device,320BW does not support on device P1,13,No,Yes,R1,P2PRXBE013,6G,LPI,,,,,,,T2A,5,160,-79,UL,STA63,,,,,,,85,COE Test Case,Weekend evening CGW452,Weekend evening CGW453 P1,13,No,No,R1,P2PRXBE014,6G,LPI,,,,,,,T1B,5,160,-70,UL,STA63,,,,,,,85,P2P Only,Day 2,Day 2 P1,13,No,No,R1,P2PRXBE015,6G,LPI,,,,,,,T1C,5,160,-45,UL,STA63,,,,,,,85,P2P Only,, @@ -27,6 +29,10 @@ P1,17,No,Yes,R1,P2PRXBE019,6G,LPI,,,,,,,T2A,197,160,-79,UL,STA63,,,,,,,85,COE Te P1,17,No,No,R1,P2PRXBE020,6G,LPI,,,,,,,T1B,197,160,-70,UL,STA63,,,,,,,85,P2P Only,Day 2,Day 2 P1,17,No,No,R1,P2PRXBE021,6G,LPI,,,,,,,T1C,197,160,-45,UL,STA63,,,,,,,85,P2P Only,, P2,18,Yes,Yes,R1,COERXBE011,6G,LPI,T1F,161,80,-76,DL,STA56,T2A,197,160,-79,UL,STA63,T3E,6,20,-70,DL,STA4,144,COE Test Case,COE test case. No plan to run,Weekend evening CGW453 +P1,21,No,Yes,R1,P2PRXBE025,6G,LPI,,,,,,,T2A,133,320,-79,UL,STA63,,,,,,,0,COE Test Case,320BW does not support on device,320BW does not support on device +P1,21,No,No,R1,P2PRXBE026,6G,LPI,,,,,,,T1B,133,320,-70,UL,STA63,,,,,,,0,P2P Only,320BW does not support on device,320BW does not support on device +P1,21,No,No,R1,P2PRXBE027,6G,LPI,,,,,,,T1C,133,320,-45,UL,STA63,,,,,,,0,P2P Only,320BW does not support on device,320BW does not support on device +P2,22,Yes,Yes,R1,COERXBE013,6G,LPI,T1F,161,80,-76,DL,STA56,T2A,133,320,-79,UL,STA63,T3E,6,20,-70,DL,STA4,0,COE Test Case,320BW does not support on device,320BW does not support on device P1,23,No,Yes,R2,P2PRXBE028,2G,LPI,,,,,,,,,,,,,T3E,1,20,-70,UL,STA63,85,COE Test Case,, P1,23,No,No,R2,P2PRXBE029,2G,LPI,,,,,,,,,,,,,T1D,1,20,-60,UL,STA63,85,P2P Only,, P1,23,No,No,R2,P2PRXBE030,2G,LPI,,,,,,,,,,,,,T1C,1,20,-45,UL,STA63,85,P2P Only,, @@ -55,6 +61,7 @@ P2,7,Yes,Yes,R3,COETXBE004,5G,LPI,T1F,161,80,-76,DL,STA63,T2A,133,160,-79,DL,STA P1,8,No,Yes,R3,P2PTXBE010,5G,LPI,T1F,100,160,-76,DL,STA63,,,,,,,,,,,,,85,COE Test Case,, P1,8,No,No,R3,P2PTXBE011,5G,LPI,T1B,100,160,-67,DL,STA63,,,,,,,,,,,,,85,P2P Only,, P1,8,No,No,R3,P2PTXBE012,5G,LPI,T1C,100,160,-45,DL,STA63,,,,,,,,,,,,,85,P2P Only,, +P2,10,Yes,Yes,R3,COETXBE006,5G,LPI,T1F,100,160,-76,DL,STA63,T2A,133,320,-79,DL,STA64,T3E,6,20,-70,DL,STA4,0,COE Test Case,320BW does not support on device,320BW does not support on device P1,11,No,Yes,R1,P2PTXBE013,6G,LPI,T2A,5,160,-79,DL,STA63,,,,,,,,,,,,,85,COE Test Case,Weekend evening CGW452,Weekend evening CGW453 P1,11,No,No,R1,P2PTXBE014,6G,LPI,T1B,5,160,-70,DL,STA63,,,,,,,,,,,,,85,P2P Only,Day 2,Day 2 P1,11,No,No,R1,P2PTXBE015,6G,LPI,T1C,5,160,-45,DL,STA63,,,,,,,,,,,,,85,P2P Only,Day 1,Day 1 @@ -67,6 +74,10 @@ P1,15,No,Yes,R1,P2PTXBE019,6G,LPI,T2A,197,160,-79,DL,STA63,,,,,,,,,,,,,85,COE Te P1,15,No,No,R1,P2PTXBE020,6G,LPI,T1B,197,160,-70,DL,STA63,,,,,,,,,,,,,85,P2P Only,Day 2,Day 2 P1,15,No,No,R1,P2PTXBE021,6G,LPI,T1C,197,160,-45,DL,STA63,,,,,,,,,,,,,85,P2P Only,Day 1,Day 1 P2,16,Yes,Yes,R1,COETXBE009,6G,LPI,T1F,161,80,-76,DL,STA56,T2A,197,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4,144,COE Test Case,COE test case. No plan to run,Weekend evening CGW453 +P1,19,No,Yes,R1,P2PTXBE025,6G,LPI,T2A,133,320,-79,DL,STA63,,,,,,,,,,,,,0,COE Test Case,320BW does not support on device,320BW does not support on device +P1,19,No,No,R1,P2PTXBE026,6G,LPI,T1B,133,320,-70,DL,STA63,,,,,,,,,,,,,0,P2P Only,320BW does not support on device,320BW does not support on device +P1,19,No,No,R1,P2PTXBE027,6G,LPI,T1C,133,320,-45,DL,STA63,,,,,,,,,,,,,0,P2P Only,320BW does not support on device,320BW does not support on device +P2,20,Yes,Yes,R1,COETXBE011,6G,LPI,T1F,161,80,-76,DL,STA56,T2A,133,320,-79,DL,STA63,T3E,6,20,-70,DL,STA4,0,COE Test Case,320BW does not support on device,320BW does not support on device P1,21,No,Yes,R2,P2PTXBE028,2G,LPI,T3E,1,20,-70,UL,STA63,,,,,,,,,,,,,85,COE Test Case,, P1,21,No,No,R2,P2PTXBE029,2G,LPI,T1D,1,20,-60,UL,STA63,,,,,,,,,,,,,85,P2P Only,Day 4,Day 4 P1,21,No,No,R2,P2PTXBE030,2G,LPI,T1C,1,20,-45,UL,STA63,,,,,,,,,,,,,85,P2P Only,, @@ -92,9 +103,11 @@ P1,5,No,No,R1,P2PRXAX008,5G,LPI,T1B,161,80,-67,UL,STA56,,,,,,,,,,,,,85,P2P Only, P1,5,No,No,R1,P2PRXAX009,5G,LPI,T1C,161,80,-45,UL,STA56,,,,,,,,,,,,,85,P2P Only,Day 2,Day 2 P2,6,Yes,Yes,R1,COERXAX003,5G,LPI,T1F,161,80,-76,UL,STA56,T2A,5,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4,144,COE Test Case,COE test case. No plan to run,Weekend evening CGW453 P2,7,Yes,Yes,R1,COERXAX004,5G,LPI,T1F,161,80,-76,UL,STA56,T2A,133,160,-79,DL,STA63,T3E,6,20,-70,DL,STA4,144,COE Test Case,COE test case. No plan to run,Weekend evening CGW453 +P2,9,Yes,Yes,R1,COERXAX006,5G,LPI,T1F,161,80,-76,UL,STA56,T2A,133,320,-79,DL,STA63,T3E,6,20,-70,DL,STA4,0,COE Test Case,320BW does not support on device,320BW does not support on device P1,10,No,Yes,R1,P2PRXAX010,5G,LPI,T1F,100,160,-76,UL,STA56,,,,,,,,,,,,,85,COE Test Case,Weekend evening CGW452,Weekend evening CGW453 P1,10,No,No,R1,P2PRXAX011,5G,LPI,T1B,100,160,-67,UL,STA56,,,,,,,,,,,,,85,P2P Only,Day 1,Day 1 P1,10,No,No,R1,P2PRXAX012,5G,LPI,T1C,100,160,-45,UL,STA56,,,,,,,,,,,,,85,P2P Only,Day 2,Day 2 +P2,12,Yes,Yes,R1,COERXAX008,5G,LPI,T1F,100,160,-76,UL,STA56,T2A,133,320,-79,DL,STA63,T3E,6,20,-70,DL,STA4,0,COE Test Case,320BW does not support on device,320BW does not support on device P1,13,No,Yes,R2,P2PRXAX013,6G,LPI,,,,,,,T2A,5,160,-79,UL,STA56,,,,,,,85,COE Test Case,, P1,13,No,No,R2,P2PRXAX014,6G,LPI,,,,,,,T1B,5,160,-70,UL,STA56,,,,,,,85,P2P Only,, P1,13,No,No,R2,P2PRXAX015,6G,LPI,,,,,,,T1C,5,160,-45,UL,STA56,,,,,,,85,P2P Only,, @@ -107,6 +120,10 @@ P1,17,No,Yes,R2,P2PRXAX019,6G,LPI,,,,,,,T2A,197,160,-79,UL,STA56,,,,,,,85,COE Te P1,17,No,No,R2,P2PRXAX020,6G,LPI,,,,,,,T1B,197,160,-70,UL,STA56,,,,,,,85,P2P Only,, P1,17,No,No,R2,P2PRXAX021,6G,LPI,,,,,,,T1C,197,160,-45,UL,STA56,,,,,,,85,P2P Only,, P2,18,Yes,Yes,R2,COERXAX011,6G,LPI,T1F,161,80,-76,DL,STA63,T2A,197,160,-79,UL,STA56,T3E,6,20,-70,DL,STA4,144,COE Test Case,COE test case. No plan to run, +P1,21,No,Yes,R2,P2PRXAX025,6G,LPI,,,,,,,T2A,133,320,-79,UL,STA56,,,,,,,0,COE Test Case,320BW does not support on device,320BW does not support on device +P1,21,No,No,R2,P2PRXAX026,6G,LPI,,,,,,,T1B,133,320,-70,UL,STA56,,,,,,,0,P2P Only,320BW does not support on device,320BW does not support on device +P1,21,No,No,R2,P2PRXAX027,6G,LPI,,,,,,,T1C,133,320,-45,UL,STA56,,,,,,,0,P2P Only,320BW does not support on device,320BW does not support on device +P2,22,Yes,Yes,R2,COERXAX013,6G,LPI,T1F,161,80,-76,DL,STA63,T2A,133,320,-79,UL,STA56,T3E,6,20,-70,DL,STA4,0,COE Test Case,320BW does not support on device,320BW does not support on device P1,23,No,Yes,R3,P2PRXAX028,2G,LPI,,,,,,,,,,,,,T3E,1,20,-70,UL,STA56,85,COE Test Case,, P1,23,No,No,R3,P2PRXAX029,2G,LPI,,,,,,,,,,,,,T1D,1,20,-60,UL,STA56,85,P2P Only,, P1,23,No,No,R3,P2PRXAX030,2G,LPI,,,,,,,,,,,,,T1C,1,20,-45,UL,STA56,85,P2P Only,, @@ -135,6 +152,7 @@ P2,7,Yes,Yes,R1,COETXAX004,5G,LPI,T1F,161,80,-76,DL,STA56,T2A,133,160,-79,DL,STA P1,8,No,Yes,R1,P2PTXAX010,5G,LPI,T1F,100,160,-76,DL,STA56,,,,,,,,,,,,,85,COE Test Case,Weekend evening CGW452,Weekend evening CGW453 P1,8,No,No,R1,P2PTXAX011,5G,LPI,T1B,100,160,-67,DL,STA56,,,,,,,,,,,,,85,P2P Only,Day 1,Day 1 P1,8,No,No,R1,P2PTXAX012,5G,LPI,T1C,100,160,-45,DL,STA56,,,,,,,,,,,,,85,P2P Only,Day 3,Day 3 +P2,10,Yes,Yes,R1,COETXAX006,5G,LPI,T1F,100,160,-76,DL,STA56,T2A,133,320,-79,DL,STA63,T3E,6,20,-70,DL,STA4,0,COE Test Case,320BW does not support on device,320BW does not support on device P1,11,No,Yes,R2,P2PTXAX013,6G,LPI,,,,,,,T2A,5,160,-79,DL,STA56,,,,,,,85,COE Test Case,, P1,11,No,No,R2,P2PTXAX014,6G,LPI,,,,,,,T1B,5,160,-70,DL,STA56,,,,,,,85,P2P Only,, P1,11,No,No,R2,P2PTXAX015,6G,LPI,,,,,,,T1C,5,160,-45,DL,STA56,,,,,,,85,P2P Only,, @@ -147,6 +165,10 @@ P1,15,No,Yes,R2,P2PTXAX019,6G,LPI,,,,,,,T2A,197,160,-79,DL,STA56,,,,,,,85,COE Te P1,15,No,No,R2,P2PTXAX020,6G,LPI,,,,,,,T1B,197,160,-70,DL,STA56,,,,,,,85,P2P Only,, P1,15,No,No,R2,P2PTXAX021,6G,LPI,,,,,,,T1C,197,160,-45,DL,STA56,,,,,,,85,P2P Only,, P2,16,Yes,Yes,R2,COETXAX009,6G,LPI,T1F,161,80,-76,DL,STA63,T2A,197,160,-79,DL,STA56,T3E,6,20,-70,DL,STA4,144,COE Test Case,COE test case. No plan to run, +P1,19,No,Yes,R2,P2PTXAX025,6G,LPI,,,,,,,T2A,133,320,-79,DL,STA56,,,,,,,0,COE Test Case,320BW does not support on device,320BW does not support on device +P1,19,No,No,R2,P2PTXAX026,6G,LPI,,,,,,,T1B,133,320,-70,DL,STA56,,,,,,,0,P2P Only,320BW does not support on device,320BW does not support on device +P1,19,No,No,R2,P2PTXAX027,6G,LPI,,,,,,,T1C,133,320,-45,DL,STA56,,,,,,,0,P2P Only,320BW does not support on device,320BW does not support on device +P2,20,Yes,Yes,R2,COETXAX011,6G,LPI,T1F,161,80,-76,DL,STA63,T2A,133,320,-79,DL,STA56,T3E,6,20,-70,DL,STA4,0,COE Test Case,320BW does not support on device,320BW does not support on device P1,21,No,Yes,R3,P2PTXAX028,2G,LPI,,,,,,,,,,,,,T3E,1,20,-70,UL,STA56,85,COE Test Case,, P1,21,No,No,R3,P2PTXAX029,2G,LPI,,,,,,,,,,,,,T1D,1,20,-60,UL,STA56,85,P2P Only,, P1,21,No,No,R3,P2PTXAX030,2G,LPI,,,,,,,,,,,,,T1C,1,20,-45,UL,STA56,85,P2P Only,, @@ -204,3 +226,4 @@ P2,17,Yes,Yes,R1,COETXAC004,2G,LPI,T1F,36,80,-76,DL,STA56,T2A,133,160,OFF,DL,STA P1,18,No,Yes,R1,P2PTXAC016,2G,LPI,,,,,,,,,,,,,T3E,11,20,-70,DL,STA4,85,COE Test Case,Weekend evening CGW452, P1,18,No,No,R1,P2PTXAC017,2G,LPI,,,,,,,,,,,,,T1D,11,20,-60,DL,STA4,85,P2P Only,Day 3,Day 3 P1,18,No,No,R1,P2PTXAC018,2G,LPI,,,,,,,,,,,,,T1C,11,20,-45,DL,STA4,85,P2P Only,Day 4,Day 4 +P2,19,Yes,Yes,R1,COETXAC005,2G,LPI,T1F,36,80,-76,DL,STA56,T2A,5,320,OFF,DL,STA63,T3E,11,20,-70,DL,STA4,0,COE Test Case,320BW does not support on device,320BW does not support on device diff --git a/backend/db.py b/backend/db.py index 7e48e9b..8bc7f77 100644 --- a/backend/db.py +++ b/backend/db.py @@ -19,6 +19,7 @@ REF = os.getenv("REF", "CGW452").strip() # Device names for the test database (use hardware device names) DEVICE_DUT = DUT DEVICE_REF = REF +MAX_SCHEDULE_VERSIONS = 50 @dataclass(frozen=True) class TestRecord: @@ -139,6 +140,20 @@ def init_db(db_path: str | Path = DB_PATH) -> None: _ensure_column(conn, "tests", "excluded", "INTEGER NOT NULL DEFAULT 0") _ensure_column(conn, "tests", "throttled", "INTEGER NOT NULL DEFAULT 0") _ensure_column(conn, "tests", "station_testpoint_map", "TEXT") + _ensure_column(conn, "schedules", "status_snapshot", "TEXT") + conn.execute( + """ + UPDATE schedules + SET status_snapshot = COALESCE(( + SELECT t.status + FROM tests t + WHERE t.test_id = schedules.test_id + AND t.device = schedules.device + ), 'pending') + WHERE status_snapshot IS NULL + """ + ) + prune_schedule_versions(conn=conn) # Seed runtime defaults for schedule estimation. conn.execute( @@ -454,7 +469,7 @@ def list_holidays(db_path: str | Path = DB_PATH) -> set[str]: def create_schedule_version( - entries: list[tuple[str, str, str, int, int]], + entries: list[tuple[str, str, str, int, int, str]], db_path: str | Path = DB_PATH, ) -> int: with get_connection(db_path) as conn: @@ -464,18 +479,102 @@ def create_schedule_version( if entries: conn.executemany( """ - INSERT INTO schedules(schedule_version, test_id, device, scheduled_date, shift_index, sequence_in_shift) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO schedules( + schedule_version, + test_id, + device, + scheduled_date, + shift_index, + sequence_in_shift, + status_snapshot + ) + VALUES (?, ?, ?, ?, ?, ?, ?) """, [ - (next_version, test_id, device, scheduled_date, shift_index, sequence) - for test_id, device, scheduled_date, shift_index, sequence in entries + (next_version, test_id, device, scheduled_date, shift_index, sequence, status_snapshot) + for test_id, device, scheduled_date, shift_index, sequence, status_snapshot in entries ], ) + prune_schedule_versions(conn=conn) + return next_version +def prune_schedule_versions( + max_versions: int = MAX_SCHEDULE_VERSIONS, + db_path: str | Path = DB_PATH, + conn: sqlite3.Connection | None = None, +) -> int: + if max_versions <= 0: + raise ValueError("max_versions must be positive") + + def _prune(active_conn: sqlite3.Connection) -> int: + cursor = active_conn.execute( + """ + DELETE FROM schedules + WHERE schedule_version IN ( + SELECT schedule_version + FROM ( + SELECT schedule_version + FROM schedules + GROUP BY schedule_version + ORDER BY schedule_version DESC + LIMIT -1 OFFSET ? + ) + ) + """, + (max_versions,), + ) + return cursor.rowcount + + if conn is not None: + return _prune(conn) + + with get_connection(db_path) as active_conn: + return _prune(active_conn) + + +def _resolve_requested_schedule_version( + conn: sqlite3.Connection, + version: int | None, +) -> tuple[int | None, int | None]: + latest_row = conn.execute("SELECT MAX(schedule_version) AS latest FROM schedules").fetchone() + latest = latest_row["latest"] + latest_version = int(latest) if latest is not None else None + + if version is not None: + row = conn.execute( + "SELECT 1 AS exists_row FROM schedules WHERE schedule_version = ? LIMIT 1", + (version,), + ).fetchone() + return (int(version) if row else None, latest_version) + + return latest_version, latest_version + + +def _hydrate_schedule_rows(rows: list[sqlite3.Row]) -> list[ScheduleRow]: + result: list[ScheduleRow] = [] + for row in rows: + result.append( + ScheduleRow( + test_id=row["test_id"], + device=row["device"], + scheduled_date=row["scheduled_date"], + shift_index=int(row["shift_index"]), + sequence_in_shift=int(row["sequence_in_shift"]), + test_type=row["test_type"], + rotation=row["rotation"], + config=json.loads(row["config_json"] or "{}"), + status=row["status"], + priority=int(row["priority"]), + estimated_minutes=int(row["estimated_minutes"]), + ) + ) + + return result + + def get_schedule_versions(db_path: str | Path = DB_PATH) -> list[dict[str, Any]]: with get_connection(db_path) as conn: rows = conn.execute( @@ -505,16 +604,8 @@ def resolve_schedule_version( 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 + selected_version, _latest_version = _resolve_requested_schedule_version(conn, version) + return selected_version def get_schedule_week( @@ -523,26 +614,15 @@ def get_schedule_week( 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) - + selected_version, latest_version = _resolve_requested_schedule_version(conn, version) if selected_version is None: return [] + use_live_status = latest_version is not None and selected_version == latest_version + status_sql = "COALESCE(t.status, s.status_snapshot, 'pending')" if use_live_status else "COALESCE(s.status_snapshot, 'pending')" + rows = conn.execute( - """ + f""" SELECT s.test_id, s.device, @@ -552,7 +632,7 @@ def get_schedule_week( t.test_type, t.rotation, t.config_json, - t.status, + {status_sql} AS status, t.priority, t.estimated_minutes FROM schedules s @@ -586,25 +666,7 @@ def get_schedule_week( ), ).fetchall() - result: list[ScheduleRow] = [] - for row in rows: - result.append( - ScheduleRow( - test_id=row["test_id"], - device=row["device"], - scheduled_date=row["scheduled_date"], - shift_index=int(row["shift_index"]), - sequence_in_shift=int(row["sequence_in_shift"]), - test_type=row["test_type"], - rotation=row["rotation"], - config=json.loads(row["config_json"] or "{}"), - status=row["status"], - priority=int(row["priority"]), - estimated_minutes=int(row["estimated_minutes"]), - ) - ) - - return result + return _hydrate_schedule_rows(rows) def get_schedule_rows( @@ -612,26 +674,15 @@ def get_schedule_rows( 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) - + selected_version, latest_version = _resolve_requested_schedule_version(conn, version) if selected_version is None: return [] + use_live_status = latest_version is not None and selected_version == latest_version + status_sql = "COALESCE(t.status, s.status_snapshot, 'pending')" if use_live_status else "COALESCE(s.status_snapshot, 'pending')" + rows = conn.execute( - """ + f""" SELECT s.test_id, s.device, @@ -641,7 +692,7 @@ def get_schedule_rows( t.test_type, t.rotation, t.config_json, - t.status, + {status_sql} AS status, t.priority, t.estimated_minutes FROM schedules s @@ -652,25 +703,7 @@ def get_schedule_rows( (selected_version,), ).fetchall() - result: list[ScheduleRow] = [] - for row in rows: - result.append( - ScheduleRow( - test_id=row["test_id"], - device=row["device"], - scheduled_date=row["scheduled_date"], - shift_index=int(row["shift_index"]), - sequence_in_shift=int(row["sequence_in_shift"]), - test_type=row["test_type"], - rotation=row["rotation"], - config=json.loads(row["config_json"] or "{}"), - status=row["status"], - priority=int(row["priority"]), - estimated_minutes=int(row["estimated_minutes"]), - ) - ) - - return result + return _hydrate_schedule_rows(rows) def reset_completed_to_pending(db_path: str | Path = DB_PATH) -> int: """Reset all 'completed' tests back to 'pending'. Used before a full directory rescan.""" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 72d709f..1715fff 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -119,6 +119,87 @@ function toTonightConfigRows(scheduleData) { .sort((a, b) => a.sta.localeCompare(b.sta)) } +const SHIFT_KEYS = ['shift1', 'shift2', 'shift3'] +const SHIFT_INDEX_BY_KEY = { + shift1: 1, + shift2: 2, + shift3: 3, +} + +function createEmptyScheduleDay() { + return { shift1: [], shift2: [], shift3: [] } +} + +function getShiftTests(scheduleData, dateKey, shiftKey) { + return scheduleData[dateKey]?.[shiftKey] ?? [] +} + +function isOnOrAfterScheduleStart(dateKey, shiftKey, scheduleStartShift) { + if (!scheduleStartShift?.date || !scheduleStartShift?.shiftIndex) return false + + if (dateKey > scheduleStartShift.date) return true + if (dateKey < scheduleStartShift.date) return false + + const currentShiftIndex = SHIFT_INDEX_BY_KEY[shiftKey] ?? 99 + return currentShiftIndex >= scheduleStartShift.shiftIndex +} + +function collectSnapshotShiftKeys(currentScheduleData = {}, previousScheduleData = {}, scheduleStartShift = null) { + const snapshotShiftKeys = new Set() + const dateKeys = new Set([ + ...Object.keys(currentScheduleData), + ...Object.keys(previousScheduleData), + ]) + + for (const dateKey of dateKeys) { + for (const shiftKey of SHIFT_KEYS) { + if (isOnOrAfterScheduleStart(dateKey, shiftKey, scheduleStartShift)) { + continue + } + + const currentTests = getShiftTests(currentScheduleData, dateKey, shiftKey) + const previousTests = getShiftTests(previousScheduleData, dateKey, shiftKey) + + // Show historical snapshot only when this shift disappeared in the latest schedule. + if (currentTests.length === 0 && previousTests.length > 0) { + snapshotShiftKeys.add(`${dateKey}::${shiftKey}`) + } + } + } + + return snapshotShiftKeys +} + +function markHistoricalSnapshot(tests = []) { + return tests.map((test) => ({ + ...test, + isPreviousVersionSnapshot: true, + })) +} + +function buildCalendarScheduleData(currentScheduleData = {}, previousScheduleData = {}, snapshotShiftKeys = new Set()) { + const calendarScheduleData = {} + const dateKeys = new Set([ + ...Object.keys(currentScheduleData), + ...Object.keys(previousScheduleData), + ]) + + for (const dateKey of dateKeys) { + const mergedDay = createEmptyScheduleDay() + + for (const shiftKey of SHIFT_KEYS) { + const shiftSnapshotKey = `${dateKey}::${shiftKey}` + mergedDay[shiftKey] = snapshotShiftKeys.has(shiftSnapshotKey) + ? markHistoricalSnapshot(getShiftTests(previousScheduleData, dateKey, shiftKey)) + : getShiftTests(currentScheduleData, dateKey, shiftKey) + } + + calendarScheduleData[dateKey] = mergedDay + } + + return calendarScheduleData +} + const DEFAULT_SETTINGS = { p2pCoeCsvPath: '', p3pCsvPath: '', @@ -171,6 +252,8 @@ export default function App() { const [startDateOverride, setStartDateOverride] = useState('') const [failedTests, setFailedTests] = useState([]) const [scheduleData, setScheduleData] = useState({}) + const [previousScheduleData, setPreviousScheduleData] = useState({}) + const [scheduleStartShift, setScheduleStartShift] = useState(null) const [scheduleWindows, setScheduleWindows] = useState([]) const [selectedWindowId, setSelectedWindowId] = useState(null) const [completionDate, setCompletionDate] = useState(null) @@ -178,6 +261,14 @@ export default function App() { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const tonightConfigRows = useMemo(() => toTonightConfigRows(scheduleData), [scheduleData]) + const snapshotShiftKeys = useMemo( + () => collectSnapshotShiftKeys(scheduleData, previousScheduleData, scheduleStartShift), + [scheduleData, previousScheduleData, scheduleStartShift], + ) + const calendarScheduleData = useMemo( + () => buildCalendarScheduleData(scheduleData, previousScheduleData, snapshotShiftKeys), + [scheduleData, previousScheduleData, snapshotShiftKeys], + ) const windowLookup = useMemo(() => { const lookup = new Map() @@ -205,7 +296,27 @@ export default function App() { const fetchSchedule = useCallback(async (start) => { try { const data = await api.getScheduleWeek(toKey(start)) + let previousVersionScheduleData = {} + + if ((data.schedule_version ?? 0) > 1) { + try { + const previousData = await api.getScheduleWeek(toKey(start), data.schedule_version - 1) + previousVersionScheduleData = groupScheduleItems(previousData.items) + } catch (previousError) { + console.warn('Failed to fetch previous schedule version:', previousError) + } + } + setScheduleData(groupScheduleItems(data.items)) + setPreviousScheduleData(previousVersionScheduleData) + if (data.schedule_start_date && data.schedule_start_shift_index) { + setScheduleStartShift({ + date: data.schedule_start_date, + shiftIndex: data.schedule_start_shift_index, + }) + } else { + setScheduleStartShift(null) + } setScheduleWindows(data.windows ?? []) setCompletionDate(data.completion_date ?? null) } catch (e) { @@ -386,7 +497,7 @@ export default function App() {
diff --git a/frontend/src/api.js b/frontend/src/api.js index 8983519..80099b5 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -30,7 +30,13 @@ export const api = { // Schedule compileSchedule: (opts) => request('POST', '/schedule/compile', opts), - getScheduleWeek: (start) => request('GET', `/schedule/week?start=${start}`), + 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..ab6f4eb 100644 --- a/frontend/src/components/Calendar.jsx +++ b/frontend/src/components/Calendar.jsx @@ -57,6 +57,7 @@ export default function Calendar({ dualDeviceWeekendWeekEnabled = false, onDualDeviceWeekendWeekEnabledChange, windowLookup = new Map(), + snapshotShiftKeys = new Set(), onWindowSelect, }) { const DEFAULT_SHIFT_MIN_ROWS = 3 @@ -226,6 +227,7 @@ export default function Calendar({ slotHeights={slotHeights} showShift2={isWeekend || (daytimeDateKey !== null && key === daytimeDateKey)} windowLookup={windowLookup} + snapshotShiftKeys={snapshotShiftKeys} onWindowSelect={onWindowSelect} /> ) diff --git a/frontend/src/components/DayColumn.jsx b/frontend/src/components/DayColumn.jsx index e360dee..97ebc2a 100644 --- a/frontend/src/components/DayColumn.jsx +++ b/frontend/src/components/DayColumn.jsx @@ -13,6 +13,7 @@ export default function DayColumn({ shiftMinRows = { shift2: 1, shift3: 1, nextDayShift1: 1 }, slotHeights = { shift2: 0, shift3: 0, nextDayShift1: 0 }, windowLookup = new Map(), + snapshotShiftKeys = new Set(), onWindowSelect, }) { const dayName = DAY_NAMES[date.getDay()] @@ -20,11 +21,17 @@ export default function DayColumn({ const dateKey = date.toISOString().slice(0, 10) const nextDateKey = new Date(date.getTime() + (24 * 60 * 60 * 1000)).toISOString().slice(0, 10) + function isSnapshotShift(snapshotDateKey, shiftIndex) { + return snapshotShiftKeys.has(`${snapshotDateKey}::shift${shiftIndex}`) + } + function getWindowForShift(shiftIndex) { + if (isSnapshotShift(dateKey, shiftIndex)) return null return windowLookup.get(`${dateKey}::shift${shiftIndex}`) ?? null } function getNextDayShift1Window() { + if (isSnapshotShift(nextDateKey, 1)) return null return windowLookup.get(`${nextDateKey}::shift1`) ?? null } diff --git a/frontend/src/components/TestCard.jsx b/frontend/src/components/TestCard.jsx index 42b913e..a75a1af 100644 --- a/frontend/src/components/TestCard.jsx +++ b/frontend/src/components/TestCard.jsx @@ -50,6 +50,7 @@ export default function TestCard({ test }) { const normalizedStatus = String(test?.status ?? 'pending').trim().toLowerCase() const style = STATUS_STYLES[normalizedStatus] ?? STATUS_STYLES.pending const deviceAccentClass = getDeviceAccentClass(test.device) + const isPreviousVersionSnapshot = Boolean(test?.isPreviousVersionSnapshot) const cardRef = useRef(null) const [tooltipPos, setTooltipPos] = useState({ top: '0px', left: '0px' }) const [isHovered, setIsHovered] = useState(false) @@ -92,7 +93,7 @@ export default function TestCard({ test }) {