diff --git a/DESIGNPLAN.md b/DESIGNPLAN.md index 754f227..b69627e 100644 --- a/DESIGNPLAN.md +++ b/DESIGNPLAN.md @@ -84,23 +84,6 @@ A test scheduler for the NJTH which compiles a best schedule according to the ru - **Estimated Completion Date** - **Settings** - Paths to target and result directories - - Rotation definition - - Let user define rotation rules, two different fields, one for P2P/COE tests, one for P3P tests, then display the rules with two tables Ex: - - P2P/COE - - | Rotation | 5G | 6G | 2G | - |----------|----|----|----| - | ROT1 | STA56 | STA63 | STA4 | - | ROT2 | STA4 | STA56 | STA63 | - | ROT3 | STA63 | | STA56 | - - - P3P - - | Rotation | 5G | 6G | 2G | - |----------|----|----|----| - | ROT1 | STA56/58/59 | STA63/64/65 | STA4/5/6 | - | ROT2 | STA4/5/6 | STA56/58/59 | STA63/64/65 | - | ROT3 | STA63/64/65 | | STA56/58/59 | - Test exclusion - User can enter rules to exclude tests that will not be run - Manual priority override diff --git a/backend/app.py b/backend/app.py index 0583be4..31eafed 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,9 +1,11 @@ +from contextlib import asynccontextmanager from pathlib import Path from typing import Any from datetime import date, datetime import os from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field import db @@ -15,9 +17,7 @@ from scheduler import SchedulerTest, compile_schedule, remove_from_active, reset APP_ROOT = Path(__file__).resolve().parent DB_PATH = APP_ROOT / "scheduler.db" DUT = os.getenv("DUT", "CGW453").strip() -REF = os.getenv("REF", "CGW452").strip() - -app = FastAPI(title="Scheduler API", version="0.1.0") +REF = os.getenv("REF", "CGW452").strip() class LoadTestsRequest(BaseModel): @@ -40,10 +40,25 @@ class RemoveActiveTestsRequest(BaseModel): test_ids: list[str] = Field(default_factory=list) -@app.on_event("startup") -def on_startup() -> None: +class SaveHolidaysRequest(BaseModel): + dates: list[str] = Field(default_factory=list) + + +@asynccontextmanager +async def lifespan(application: FastAPI): db.init_db(DB_PATH) graph.reset_graph_state() + yield + + +app = FastAPI(title="Scheduler API", version="0.1.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], + allow_methods=["*"], + allow_headers=["*"], +) @app.get("/api/health") @@ -154,6 +169,18 @@ def compile_schedule_endpoint(request: CompileScheduleRequest) -> dict[str, Any] } +@app.post("/api/holidays") +def save_holidays(request: SaveHolidaysRequest) -> dict[str, Any]: + dates = [d.strip() for d in request.dates if d.strip()] + db.upsert_holidays(dates, DB_PATH) + return {"status": "saved", "count": len(dates)} + + +@app.get("/api/holidays") +def get_holidays() -> dict[str, Any]: + return {"dates": sorted(db.list_holidays(DB_PATH))} + + @app.get("/api/schedule/week") def get_schedule_week(start: str | None = None) -> dict[str, Any]: week_start = start or date.today().isoformat() @@ -174,6 +201,7 @@ def get_schedule_week(start: str | None = None) -> dict[str, Any]: "sequence_in_shift": row.sequence_in_shift, "test_type": row.test_type, "rotation": row.rotation, + "config": row.config, "status": row.status, "priority": row.priority, "estimated_minutes": row.estimated_minutes, @@ -181,3 +209,8 @@ def get_schedule_week(start: str | None = None) -> dict[str, Any]: for row in rows ], } + +if __name__ == "__main__": + import uvicorn + uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True) + diff --git a/backend/db.py b/backend/db.py index b9259f3..2c08919 100644 --- a/backend/db.py +++ b/backend/db.py @@ -47,6 +47,7 @@ class ScheduleRow: sequence_in_shift: int test_type: str rotation: str | None + config: dict[str, dict[str, str | None]] status: str priority: int estimated_minutes: int @@ -479,11 +480,12 @@ def get_schedule_week(start_date: str, db_path: str | Path = DB_PATH) -> list[Sc s.sequence_in_shift, t.test_type, t.rotation, + t.config_json, t.status, t.priority, t.estimated_minutes FROM schedules s - JOIN tests t ON t.test_id = s.test_id + JOIN tests t ON t.test_id = s.test_id AND t.device = s.device WHERE s.schedule_version = ? AND s.scheduled_date >= ? AND s.scheduled_date < date(?, '+7 day') @@ -503,6 +505,7 @@ def get_schedule_week(start_date: str, db_path: str | Path = DB_PATH) -> list[Sc 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"]), @@ -527,3 +530,14 @@ def mark_tests_completed(test_ids_with_device: list[tuple[str, str]], db_path: s test_ids_with_device, ) + +def upsert_holidays(dates: list[str], db_path: str | Path = DB_PATH) -> None: + """Replace all holidays with the provided list of YYYY-MM-DD date strings.""" + with get_connection(db_path) as conn: + conn.execute("DELETE FROM holidays") + if dates: + conn.executemany( + "INSERT OR IGNORE INTO holidays(date) VALUES (?)", + [(d,) for d in dates], + ) + diff --git a/backend/graph.py b/backend/graph.py index 8442370..615f5a2 100644 --- a/backend/graph.py +++ b/backend/graph.py @@ -49,6 +49,10 @@ def _build_graph(tests: dict[str, Any]) -> dict[str, set[str]]: def _compatible(a: Any, b: Any) -> bool: + # Hard conflict: same testpoint but different STA assignment means they cannot share a window. + if _has_testpoint_sta_conflict(a.config, b.config): + return False + # Compatible when same rotation, or both non-P3P and overlap has identical testpoints. if a.rotation == b.rotation: return True @@ -57,6 +61,20 @@ def _compatible(a: Any, b: Any) -> bool: return False +def _has_testpoint_sta_conflict( + config_a: dict[str, dict[str, str | None]], + config_b: dict[str, dict[str, str | None]], +) -> bool: + testpoint_to_sta_a = _build_testpoint_sta_map(config_a) + testpoint_to_sta_b = _build_testpoint_sta_map(config_b) + + overlap = set(testpoint_to_sta_a.keys()) & set(testpoint_to_sta_b.keys()) + for testpoint in overlap: + if testpoint_to_sta_a[testpoint] != testpoint_to_sta_b[testpoint]: + return True + return False + + def _same_test_points_for_overlap( config_a: dict[str, dict[str, str | None]], config_b: dict[str, dict[str, str | None]], @@ -95,6 +113,30 @@ def _build_station_testpoint_map(config: dict[str, dict[str, str | None]]) -> di return station_to_testpoint +def _build_testpoint_sta_map(config: dict[str, dict[str, str | None]]) -> dict[str, set[str]]: + testpoint_to_sta: dict[str, set[str]] = {} + + def _add_entry(entry: dict[str, str | None]) -> None: + testpoint = _norm(entry.get("test_point") or entry.get("Testpoint")) + sta_raw = _norm(entry.get("sta") or entry.get("STA")) + if not testpoint or not sta_raw: + return + for sta in sta_raw.split(","): + sta_clean = _norm(sta) + if sta_clean: + testpoint_to_sta.setdefault(testpoint, set()).add(sta_clean) + + for band in ("5G", "6G", "2G"): + entry = config.get(band) or {} + _add_entry(entry) + + for station_key in ("Station 1", "Station 2", "Station 3"): + entry = config.get(station_key) or {} + _add_entry(entry) + + return testpoint_to_sta + + def _norm(value: Any) -> str: if value is None: return "" diff --git a/backend/scheduler.py b/backend/scheduler.py index 04a3713..7ae9bff 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -136,25 +136,37 @@ def compile_schedule( ref_active_priority: dict[TestKey, int] = dict(_ACTIVE_REF) entries: list[ScheduleEntry] = [] - current_date = _parse_date(start_date) + window_start_date = _parse_date(start_date) + current_date = window_start_date last_date: str | None = None + daytime_shift2_window_pending = daytime_testing_today while dut_active_priority or ref_active_priority: + is_special_daytime_shift2_window = ( + daytime_shift2_window_pending + and current_date == window_start_date + and current_date.weekday() < 5 + and current_date.isoformat() not in holiday_dates + ) + # Get the shift sequence for current date (respects Mon/Fri/weekend rules) - shift_sequence = _get_shift_sequence(current_date, holiday_dates) + shift_sequence = _get_shift_sequence( + current_date, + holiday_dates, + daytime_shift2_only=is_special_daytime_shift2_window, + ) if not shift_sequence: break # No valid shift sequence dut_active_test_ids: set[TestKey] = set(dut_active_priority.keys()) ref_active_test_ids: set[TestKey] = set(ref_active_priority.keys()) - bundles = _create_bundles(dut_active_test_ids, ref_active_test_ids, _TESTS_BY_ID) + bundles = _create_bundles(dut_active_test_ids, ref_active_test_ids, _TESTS_BY_ID, top_priority_tests) shift_capacities = { (date_obj, shift_idx): _shift_capacity_for_date( current_date=date_obj, holiday_dates=holiday_dates, - start_date_override=start_date, - daytime_testing_today=daytime_testing_today, + daytime_shift2_only=is_special_daytime_shift2_window and date_obj == current_date, ).get(shift_idx, 0) for date_obj, shift_idx in shift_sequence } @@ -173,6 +185,9 @@ def compile_schedule( if placed_last_date is not None: last_date = placed_last_date + # Daytime testing special handling applies only to the first scheduling window. + daytime_shift2_window_pending = False + # Move to next scheduling window start date # After shift 1 (1am-9am), there's shift 2 if daytime testing, then shift 3 (5pm) # After shift 3 (5pm), shift 1 is next day (1am) @@ -347,6 +362,7 @@ def _create_bundles( dut_active_test_ids: set[TestKey], ref_active_test_ids: set[TestKey], tests: dict[TestKey, SchedulerTest], + top_priority_tests: set[str] | None = None, ) -> list[TestBundle]: """Create bundles of tests that must run together, sorted by priority tier and efficiency. @@ -358,6 +374,9 @@ def _create_bundles( Returned list is sorted by (priority_tier, total_minutes) to place small/high-priority bundles first. """ + if top_priority_tests is None: + top_priority_tests = set() + bundles: list[TestBundle] = [] processed: set[TestKey] = set() @@ -393,7 +412,12 @@ def _create_bundles( if not bundle_test_ids: continue processed.update(bundle_test_ids) - priority_tier = BUNDLE_PRIORITY_P2P_WITH_COE + # Check if any test in bundle is top priority + bundle_test_ids_only = {key[0] for key in bundle_test_ids} + if bundle_test_ids_only & top_priority_tests: + priority_tier = BUNDLE_PRIORITY_FAILED # Highest priority (0) + else: + priority_tier = BUNDLE_PRIORITY_P2P_WITH_COE bundles.append(TestBundle( test_ids=bundle_test_ids, priority_tier=priority_tier, @@ -415,7 +439,12 @@ def _create_bundles( if not bundle_test_ids: continue processed.update(bundle_test_ids) - priority_tier = BUNDLE_PRIORITY_P2P_ONLY + # Check if any test in bundle is top priority + bundle_test_ids_only = {key[0] for key in bundle_test_ids} + if bundle_test_ids_only & top_priority_tests: + priority_tier = BUNDLE_PRIORITY_FAILED # Highest priority (0) + else: + priority_tier = BUNDLE_PRIORITY_P2P_ONLY bundles.append(TestBundle( test_ids=bundle_test_ids, priority_tier=priority_tier, @@ -425,7 +454,12 @@ def _create_bundles( bundle_test_ids = [key for key in (_active_key(test_id, DUT), _active_key(test_id, REF)) if key is not None and key not in processed] if not bundle_test_ids: continue - priority_tier = BUNDLE_PRIORITY_P3P + # Check if any test in bundle is top priority + bundle_test_ids_only = {key[0] for key in bundle_test_ids} + if bundle_test_ids_only & top_priority_tests: + priority_tier = BUNDLE_PRIORITY_FAILED # Highest priority (0) + else: + priority_tier = BUNDLE_PRIORITY_P3P bundles.append(TestBundle( test_ids=bundle_test_ids, priority_tier=priority_tier, @@ -439,8 +473,12 @@ def _create_bundles( if key in processed: continue test = tests[key] - priority_tier = BUNDLE_PRIORITY_COE_ONLY bundle_test_ids = [key] + # Check if this test is top priority + if key[0] in top_priority_tests: + priority_tier = BUNDLE_PRIORITY_FAILED # Highest priority (0) + else: + priority_tier = BUNDLE_PRIORITY_COE_ONLY bundles.append(TestBundle( test_ids=bundle_test_ids, priority_tier=priority_tier, @@ -466,8 +504,7 @@ def _rx_tx_pair_id(test_id: str, active: set[str]) -> str | None: def _shift_capacity_for_date( current_date: date, holiday_dates: set[str], - start_date_override: str | None, - daytime_testing_today: bool, + daytime_shift2_only: bool, ) -> dict[int, int]: iso = current_date.isoformat() if iso in holiday_dates: @@ -477,8 +514,8 @@ def _shift_capacity_for_date( if is_weekend: return {1: 480, 2: 480, 3: 480} - if start_date_override and iso == _parse_date(start_date_override).isoformat() and daytime_testing_today: - return {1: 480, 2: 480, 3: 480} + if daytime_shift2_only: + return {1: 0, 2: 480, 3: 0} return {1: 600, 2: 0, 3: 420} @@ -489,15 +526,20 @@ def _parse_date(value: str | None) -> date: return datetime.strptime(value, "%Y-%m-%d").date() -def _get_shift_sequence(start_date: date, holiday_dates: set[str]) -> list[tuple[date, int]]: +def _get_shift_sequence( + start_date: date, + holiday_dates: set[str], + daytime_shift2_only: bool = False, +) -> list[tuple[date, int]]: """Generate the sequence of (date, shift_index) tuples for a scheduling window. Rules per design: + - Daytime testing first window (weekday only): [2] (shift 2 today only) - Monday-Thursday: [3, 1] (shift 3 today, shift 1 next day) = 16 hours - Friday: [3, 1, 2, 3, 1, 2, 3, 1] (Fri-Mon) = 24 hours continuous - Saturday/Sunday/Holiday weekday: all 3 shifts - Always starts on shift 3 for weekdays. + Weekday default starts on shift 3 unless daytime testing shift-2-only is enabled. Returns ordered list of (date, shift_index) pairs. """ iso = start_date.isoformat() @@ -505,6 +547,10 @@ def _get_shift_sequence(start_date: date, holiday_dates: set[str]) -> list[tuple weekday = start_date.weekday() # 0=Mon, 4=Fri, 5=Sat, 6=Sun shifts: list[tuple[date, int]] = [] + + if daytime_shift2_only and weekday < 5 and not is_holiday: + shifts.append((start_date, 2)) + return shifts # If holiday on a weekday, treat as weekend (all 3 shifts) if is_holiday and weekday < 5: diff --git a/frontend/index.html b/frontend/index.html index 7b228d7..9819f3a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - my-react-app + CGW453 Scheduler
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b2c5a26..3ff37a0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,10 @@ "name": "my-react-app", "version": "0.0.0", "dependencies": { + "@tailwindcss/vite": "^4.3.1", "react": "^19.2.6", - "react-dom": "^19.2.6" + "react-dom": "^19.2.6", + "tailwindcss": "^4.3.1" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -267,7 +269,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -279,7 +280,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -290,7 +290,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -495,7 +494,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -506,7 +504,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -517,7 +514,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -527,14 +523,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -545,7 +539,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -564,7 +557,6 @@ "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -577,7 +569,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -594,7 +585,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -611,7 +601,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -628,7 +617,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -645,7 +633,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -662,7 +649,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], @@ -682,7 +668,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], @@ -702,7 +687,6 @@ "cpu": [ "ppc64" ], - "dev": true, "libc": [ "glibc" ], @@ -722,7 +706,6 @@ "cpu": [ "s390x" ], - "dev": true, "libc": [ "glibc" ], @@ -742,7 +725,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -762,7 +744,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], @@ -782,7 +763,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -799,7 +779,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -818,7 +797,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -835,7 +813,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -849,14 +826,281 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, "license": "MIT" }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1119,7 +1363,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -1132,6 +1375,19 @@ "dev": true, "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1362,7 +1618,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -1431,7 +1686,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1478,6 +1732,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -1545,6 +1805,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1627,7 +1896,6 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -1660,7 +1928,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1681,7 +1948,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1702,7 +1968,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1723,7 +1988,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1744,7 +2008,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1765,7 +2028,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], @@ -1789,7 +2051,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], @@ -1813,7 +2074,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -1837,7 +2097,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], @@ -1861,7 +2120,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1882,7 +2140,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1922,6 +2179,15 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -1949,7 +2215,6 @@ "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, "funding": [ { "type": "github", @@ -2055,14 +2320,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2075,7 +2338,6 @@ "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, "funding": [ { "type": "opencollective", @@ -2145,7 +2407,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", - "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.133.0", @@ -2218,17 +2479,34 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -2245,7 +2523,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD", "optional": true }, @@ -2307,7 +2584,6 @@ "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", - "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", diff --git a/frontend/package.json b/frontend/package.json index b80f45f..e630b00 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,10 @@ "preview": "vite preview" }, "dependencies": { + "@tailwindcss/vite": "^4.3.1", "react": "^19.2.6", - "react-dom": "^19.2.6" + "react-dom": "^19.2.6", + "tailwindcss": "^4.3.1" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/frontend/src/App.css b/frontend/src/App.css index f90339d..8b13789 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,184 +1 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4f03aa1..07a145d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,122 +1,249 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import heroImg from './assets/hero.png' -import './App.css' +import { useState, useEffect, useCallback, useMemo } from 'react' +import Header from './components/Header' +import FailedBanner from './components/FailedBanner' +import Calendar from './components/Calendar' +import RightPanel from './components/RightPanel' +import SettingsModal from './components/SettingsModal' +import { api, groupScheduleItems } from './api' -function App() { - const [count, setCount] = useState(0) - - return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.jsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- - ) +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function getMondayOfWeek(date) { + const d = new Date(date) + const day = d.getDay() + d.setDate(d.getDate() + (day === 0 ? -6 : 1 - day)) + d.setHours(0, 0, 0, 0) + return d } -export default App +function toKey(d) { return d.toISOString().slice(0, 10) } + +function addDays(date, n) { + const d = new Date(date) + d.setDate(d.getDate() + n) + return d +} + +function getTonightWindowKeys() { + const now = new Date() + const hour = now.getHours() + const today = new Date(now) + today.setHours(0, 0, 0, 0) + + if (hour < 1) { + return { + shift3Date: toKey(addDays(today, -1)), + shift1Date: toKey(today), + } + } + + return { + shift3Date: toKey(today), + shift1Date: toKey(addDays(today, 1)), + } +} + +function toTonightConfigRows(scheduleData) { + const { shift3Date, shift1Date } = getTonightWindowKeys() + const tonightTests = [ + ...((scheduleData[shift3Date]?.shift3) ?? []), + ...((scheduleData[shift1Date]?.shift1) ?? []), + ] + + const bySta = new Map() + + for (const test of tonightTests) { + const config = test.config ?? {} + for (const entry of Object.values(config)) { + const staRaw = entry?.sta + const testPoint = entry?.test_point + if (!staRaw || !testPoint) continue + + for (const staPart of String(staRaw).split(',')) { + const sta = staPart.trim().toUpperCase() + if (!sta) continue + + if (!bySta.has(sta)) { + bySta.set(sta, { sta, points: new Set() }) + } + bySta.get(sta).points.add(testPoint) + } + } + } + + return Array.from(bySta.values()) + .map((row) => ({ + sta: row.sta, + testPoints: Array.from(row.points).sort(), + })) + .sort((a, b) => a.sta.localeCompare(b.sta)) +} + +const DEFAULT_SETTINGS = { + p2pCoeCsvPath: '', + p3pCsvPath: '', + refResultDir: '', + dutResultDir: '', + testExclusion: '', + holidays: '', + startDateOverride: '', +} + +// --------------------------------------------------------------------------- + +export default function App() { + const [settingsOpen, setSettingsOpen] = useState(false) + const [settings, setSettings] = useState(DEFAULT_SETTINGS) + const [daytimeEnabled, setDaytimeEnabled] = useState(false) + const [topPriority, setTopPriority] = useState('') + const [lowestPriority, setLowestPriority] = useState('') + const [failedTests, setFailedTests] = useState([]) + const [scheduleData, setScheduleData] = useState({}) + const [completionDate, setCompletionDate] = useState(null) + const [weekStart, setWeekStart] = useState(() => getMondayOfWeek(new Date())) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const tonightConfigRows = useMemo(() => toTonightConfigRows(scheduleData), [scheduleData]) + + // Fetch schedule for the given weekStart (Monday) + const fetchSchedule = useCallback(async (start) => { + try { + const data = await api.getScheduleWeek(toKey(start)) + setScheduleData(groupScheduleItems(data.items)) + } catch (e) { + console.error('Failed to fetch schedule:', e) + } + }, []) + + // On mount: load settings + holidays + schedule for today's week + useEffect(() => { + async function init() { + try { + const [saved, holidayData] = await Promise.all([ + api.getSettings(), + api.getHolidays(), + ]) + setSettings(prev => ({ + ...prev, + ...saved, + holidays: (holidayData.dates ?? []).join(', '), + })) + } catch (e) { + console.warn('Backend not reachable on load:', e.message) + } + await fetchSchedule(getMondayOfWeek(new Date())) + } + init() + }, [fetchSchedule]) + + // Refetch whenever the displayed week changes + useEffect(() => { + fetchSchedule(weekStart) + }, [weekStart, fetchSchedule]) + + // ------------------------------------------------------------------------- + + async function handleSaveSettings(newSettings) { + setLoading(true) + setError(null) + try { + await api.saveSettings(newSettings) + + const holidayDates = (newSettings.holidays ?? '') + .split(',').map(s => s.trim()).filter(Boolean) + await api.saveHolidays(holidayDates) + + if (newSettings.p2pCoeCsvPath?.trim()) { + await api.loadCsv(newSettings.p2pCoeCsvPath.trim()) + } + if (newSettings.p3pCsvPath?.trim()) { + await api.loadCsv(newSettings.p3pCsvPath.trim()) + } + + setSettings(newSettings) + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + } + + async function handleRemakeSchedule() { + setLoading(true) + setError(null) + try { + const result = await api.compileSchedule({ + start_date: settings.startDateOverride?.trim() || null, + daytime_testing_today: daytimeEnabled, + top_priority_tests: topPriority.split(',').map(s => s.trim()).filter(Boolean), + lowest_priority_tests: lowestPriority.split(',').map(s => s.trim()).filter(Boolean), + rule: settings.testExclusion ?? '', + }) + setCompletionDate(result.completion_date ?? null) + await fetchSchedule(weekStart) + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + } + + function handleRerunDecision(rerunDuringDay) { + // TODO: POST /api/failed-tests/rerun-decision once backend endpoint exists + console.log('Rerun during day:', rerunDuringDay) + setFailedTests([]) + } + + // ------------------------------------------------------------------------- + + return ( +
+
setSettingsOpen(true)} /> + + {error && ( +
+ {error} + +
+ )} + + + +
+
+ +
+ + +
+ + setSettingsOpen(false)} + settings={settings} + onSave={handleSaveSettings} + /> +
+ ) +} diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..8a74040 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,47 @@ +const BASE = '/api' + +async function request(method, path, body) { + const res = await fetch(`${BASE}${path}`, { + method, + headers: body !== undefined ? { 'Content-Type': 'application/json' } : {}, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })) + throw new Error(err.detail || `HTTP ${res.status}`) + } + return res.json() +} + +export const api = { + // Health + health: () => request('GET', '/health'), + + // Settings + getSettings: () => request('GET', '/settings'), + saveSettings: (settings) => request('POST', '/settings', { settings }), + + // Holidays + getHolidays: () => request('GET', '/holidays'), + saveHolidays: (dates) => request('POST', '/holidays', { dates }), + + // Tests + loadCsv: (csv_path) => request('POST', '/tests/load', { csv_path }), + + // Schedule + compileSchedule: (opts) => request('POST', '/schedule/compile', opts), + getScheduleWeek: (start) => request('GET', `/schedule/week?start=${start}`), +} + +// Transform the flat items array from GET /api/schedule/week into the +// { "YYYY-MM-DD": { shift1: [], shift2: [], shift3: [] } } shape the Calendar expects. +export function groupScheduleItems(items = []) { + const out = {} + for (const item of items) { + const key = item.scheduled_date + if (!out[key]) out[key] = { shift1: [], shift2: [], shift3: [] } + const shiftKey = `shift${item.shift_index}` + out[key][shiftKey].push(item) + } + return out +} diff --git a/frontend/src/components/Calendar.jsx b/frontend/src/components/Calendar.jsx new file mode 100644 index 0000000..e86d14e --- /dev/null +++ b/frontend/src/components/Calendar.jsx @@ -0,0 +1,130 @@ +import DayColumn from './DayColumn' + +function addDays(date, n) { + const d = new Date(date) + d.setDate(d.getDate() + n) + return d +} + +function formatDateRange(monday) { + const sunday = addDays(monday, 6) + const opts = { month: 'short', day: 'numeric' } + const startStr = monday.toLocaleDateString('en-US', opts) + const endStr = sunday.toLocaleDateString('en-US', { + month: sunday.getMonth() !== monday.getMonth() ? 'short' : undefined, + day: 'numeric', + }) + return `${startStr} – ${endStr}` +} + +function toDateKey(date) { + return date.toISOString().slice(0, 10) +} + +function getMondayOfWeek(date) { + const d = new Date(date) + const day = d.getDay() + d.setDate(d.getDate() + (day === 0 ? -6 : 1 - day)) + d.setHours(0, 0, 0, 0) + return d +} + +// Returns the two shifts that form the next/active test window. +// Window = Shift 3 of day D → Shift 1 of day D+1 +function getNextTestWindow() { + const now = new Date() + const hour = now.getHours() + const tod = new Date(now); tod.setHours(0, 0, 0, 0) + + // midnight–1AM: still inside shift 3 that started yesterday + if (hour < 1) { + return { shift3Date: toDateKey(addDays(tod, -1)), shift1Date: toDateKey(tod) } + } + // 1AM–5PM: shift 1 finished or in daytime gap — next window starts at 5PM today + // 5PM–midnight: currently inside shift 3 of today + // Both cases: upcoming/active window is shift 3 today + shift 1 tomorrow + return { shift3Date: toDateKey(tod), shift1Date: toDateKey(addDays(tod, 1)) } +} + +// scheduleData: { "YYYY-MM-DD": { shift1: [...], shift2: [...], shift3: [...] }, ... } +export default function Calendar({ scheduleData = {}, daytimeDateKey = null, weekStart, onWeekChange }) { + const today = new Date() + today.setHours(0, 0, 0, 0) + + const activeWindow = getNextTestWindow() + + const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)) + + // Reorder columns: Sun Mon Tue Wed Thu Fri Sat + // weekStart is Monday, so days[0]=Mon ... days[6]=Sun → put Sunday first + const orderedDays = [ ...days.slice(0, 7)] + + return ( +
+ {/* Week navigation */} +
+ + + {formatDateRange(weekStart)} + + + +
+ + {/* 7-day grid */} +
+ {orderedDays.map((date) => { + const key = toDateKey(date) + const shifts = scheduleData[key] ?? { shift1: [], shift2: [], shift3: [] } + const isToday = date.getTime() === today.getTime() + const isWeekend = date.getDay() === 0 || date.getDay() === 6 + // Which shifts on this date are part of the active test window? + const activeShifts = new Set() + if (key === activeWindow.shift3Date) activeShifts.add(3) + if (key === activeWindow.shift1Date) activeShifts.add(1) + return ( + + ) + })} +
+ + {/* Legend */} +
+ Pending + Completed + Failed + Invalid +
+
+ ) +} + diff --git a/frontend/src/components/DayColumn.jsx b/frontend/src/components/DayColumn.jsx new file mode 100644 index 0000000..1c5a6aa --- /dev/null +++ b/frontend/src/components/DayColumn.jsx @@ -0,0 +1,38 @@ +import ShiftSlot from './ShiftSlot' + +const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + +export default function DayColumn({ date, isToday, activeShifts = new Set(), shifts = {}, showShift2 = false }) { + const dayName = DAY_NAMES[date.getDay()] + const dayNum = date.getDate() + const isWeekend = date.getDay() === 0 || date.getDay() === 6 + const hasActive = activeShifts.size > 0 + + // Shift 2 is visible if: weekend, holiday (showShift2), or daytime testing on + const shift2Visible = isWeekend || showShift2 + + return ( +
+ {/* Day header — subtle today ring, no full-column highlight */} +
+

+ {dayName} +

+

+ {dayNum} +

+
+ + {/* Shifts */} +
+ + + +
+
+ ) +} diff --git a/frontend/src/components/FailedBanner.jsx b/frontend/src/components/FailedBanner.jsx new file mode 100644 index 0000000..b80fc2b --- /dev/null +++ b/frontend/src/components/FailedBanner.jsx @@ -0,0 +1,51 @@ +export default function FailedBanner({ failedTests, estimatedMinutes, onDecision }) { + if (!failedTests || failedTests.length === 0) return null + + const hours = Math.floor(estimatedMinutes / 60) + const mins = estimatedMinutes % 60 + const timeStr = hours > 0 ? `${hours}h ${mins}m` : `${mins}m` + + return ( +
+ + + +
+

+ {failedTests.length} test{failedTests.length !== 1 ? 's' : ''} + {' '}failed last night and require a rerun —{' '} + {failedTests.join(', ')} +

+

+ Estimated rerun time: {timeStr} + {' '}— Rerun these tests during the day today? +

+
+
+ + +
+
+ ) +} diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx new file mode 100644 index 0000000..846a79b --- /dev/null +++ b/frontend/src/components/Header.jsx @@ -0,0 +1,35 @@ +export default function Header({ onOpenSettings }) { + return ( +
+

+ CGW453 Scheduler +

+ +
+ ) +} diff --git a/frontend/src/components/RightPanel.jsx b/frontend/src/components/RightPanel.jsx new file mode 100644 index 0000000..a3942fc --- /dev/null +++ b/frontend/src/components/RightPanel.jsx @@ -0,0 +1,117 @@ +function formatCompletionDate(value) { + if (!value) return null + + const parsed = new Date(`${value}T00:00:00`) + if (Number.isNaN(parsed.getTime())) return String(value) + + return parsed.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + }) +} + +export default function RightPanel({ + completionDate, + daytimeEnabled, + onDaytimeEnabledChange, + topPriority, + onTopPriorityChange, + lowestPriority, + onLowestPriorityChange, + onRemakeSchedule, + tonightConfigRows = [], + loading = false, +}) { + const formattedCompletionDate = formatCompletionDate(completionDate) + + return ( +