This commit is contained in:
2026-06-17 16:02:04 -04:00
parent 6762586e2d
commit e51a6777fc
21 changed files with 1399 additions and 506 deletions
-17
View File
@@ -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
+37 -4
View File
@@ -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
@@ -17,8 +19,6 @@ 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")
class LoadTestsRequest(BaseModel):
csv_path: str = Field(..., description="Absolute or backend-relative path to target CSV")
@@ -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)
+15 -1
View File
@@ -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],
)
+42
View File
@@ -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 ""
+61 -15
View File
@@ -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()
@@ -506,6 +548,10 @@ def _get_shift_sequence(start_date: date, holiday_dates: set[str]) -> list[tuple
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:
shifts.append((start_date, 1))
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>my-react-app</title>
<title>CGW453 Scheduler</title>
</head>
<body>
<div id="root"></div>
+328 -52
View File
@@ -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",
+3 -1
View File
@@ -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",
-183
View File
@@ -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);
}
}
+246 -119
View File
@@ -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 (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.jsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
type="button"
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
)
// ---------------------------------------------------------------------------
// 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 (
<div className="flex flex-col min-h-screen bg-gray-950 text-gray-100">
<Header onOpenSettings={() => setSettingsOpen(true)} />
{error && (
<div className="px-6 py-2 bg-orange-900/60 border-b border-orange-700 text-orange-200 text-sm flex items-center justify-between">
<span>{error}</span>
<button className="ml-3 text-orange-400 hover:text-white" onClick={() => setError(null)}></button>
</div>
)}
<FailedBanner
failedTests={failedTests}
estimatedMinutes={0}
onDecision={handleRerunDecision}
/>
<main className="flex flex-1 gap-4 p-4 overflow-hidden">
<div className="flex-1 min-w-0 flex flex-col">
<Calendar
scheduleData={scheduleData}
daytimeDateKey={daytimeEnabled ? toKey(new Date()) : null}
weekStart={weekStart}
onWeekChange={setWeekStart}
/>
</div>
<RightPanel
completionDate={completionDate}
daytimeEnabled={daytimeEnabled}
onDaytimeEnabledChange={setDaytimeEnabled}
topPriority={topPriority}
onTopPriorityChange={setTopPriority}
lowestPriority={lowestPriority}
onLowestPriorityChange={setLowestPriority}
onRemakeSchedule={handleRemakeSchedule}
loading={loading}
tonightConfigRows={tonightConfigRows}
/>
</main>
<SettingsModal
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
settings={settings}
onSave={handleSaveSettings}
/>
</div>
)
}
+47
View File
@@ -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
}
+130
View File
@@ -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)
// midnight1AM: still inside shift 3 that started yesterday
if (hour < 1) {
return { shift3Date: toDateKey(addDays(tod, -1)), shift1Date: toDateKey(tod) }
}
// 1AM5PM: shift 1 finished or in daytime gap next window starts at 5PM today
// 5PMmidnight: 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 (
<div className="flex flex-col gap-3 h-full">
{/* Week navigation */}
<div className="flex items-center gap-3">
<button
onClick={() => onWeekChange(addDays(weekStart, -7))}
className="p-1 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Previous week"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-sm font-medium text-gray-300 min-w-36 text-center">
{formatDateRange(weekStart)}
</span>
<button
onClick={() => onWeekChange(addDays(weekStart, 7))}
className="p-1 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Next week"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
<button
onClick={() => onWeekChange(getMondayOfWeek(new Date()))}
className="px-2 py-1 text-xs font-semibold text-gray-300 border border-gray-600 rounded hover:bg-gray-700 hover:text-white transition-colors"
title="Jump to this week"
>
Today
</button>
</div>
{/* 7-day grid */}
<div className="flex gap-1.5 flex-1 overflow-x-auto">
{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 (
<DayColumn
key={key}
date={date}
isToday={isToday}
activeShifts={activeShifts}
shifts={shifts}
showShift2={isWeekend || (daytimeDateKey !== null && key === daytimeDateKey)}
/>
)
})}
</div>
{/* Legend */}
<div className="flex gap-4 text-xs text-gray-400">
<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-red-700 inline-block" />Failed</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-yellow-700 inline-block" />Invalid</span>
</div>
</div>
)
}
+38
View File
@@ -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 (
<div className="flex flex-col min-w-0 flex-1 rounded-lg border border-gray-700 bg-gray-800/40">
{/* Day header — subtle today ring, no full-column highlight */}
<div
className={`text-center py-1.5 rounded-t-lg ${
isToday ? 'bg-gray-600 text-white' : 'bg-gray-700/60 text-gray-300'
}`}
>
<p className="text-[11px] font-semibold uppercase tracking-wide leading-none">
{dayName}
</p>
<p className={`text-base font-bold leading-tight ${isToday ? 'text-white' : 'text-gray-100'}`}>
{dayNum}
</p>
</div>
{/* Shifts */}
<div className="flex flex-col flex-1 px-0.5 py-1">
<ShiftSlot label="12AM9AM" tests={shifts.shift1} visible={true} active={activeShifts.has(1)} />
<ShiftSlot label="9AM5PM" tests={shifts.shift2} visible={true} active={activeShifts.has(2)} />
<ShiftSlot label="5PM12AM" tests={shifts.shift3} visible={true} active={activeShifts.has(3)} />
</div>
</div>
)
}
+51
View File
@@ -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 (
<div className="flex items-start gap-4 px-6 py-3 bg-red-900/70 border-b border-red-700 text-red-100">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-5 h-5 mt-0.5 shrink-0 text-red-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
/>
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
<span className="font-semibold">{failedTests.length} test{failedTests.length !== 1 ? 's' : ''}</span>
{' '}failed last night and require a rerun {' '}
<span className="font-mono text-red-200">{failedTests.join(', ')}</span>
</p>
<p className="text-sm text-red-300 mt-0.5">
Estimated rerun time: <span className="font-semibold text-red-100">{timeStr}</span>
{' '} Rerun these tests during the day today?
</p>
</div>
<div className="flex gap-2 shrink-0">
<button
onClick={() => onDecision(true)}
className="px-3 py-1 text-sm font-medium bg-red-700 hover:bg-red-600 text-white rounded-md border border-red-500 transition-colors"
>
Yes
</button>
<button
onClick={() => onDecision(false)}
className="px-3 py-1 text-sm font-medium bg-gray-700 hover:bg-gray-600 text-gray-100 rounded-md border border-gray-500 transition-colors"
>
No
</button>
</div>
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
export default function Header({ onOpenSettings }) {
return (
<header className="flex items-center justify-between px-6 py-3 bg-gray-900 border-b border-gray-700 shrink-0">
<h1 className="text-white font-semibold text-lg tracking-wide">
CGW453 Scheduler
</h1>
<button
onClick={onOpenSettings}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-300 bg-gray-800 border border-gray-600 rounded-md hover:bg-gray-700 hover:text-white transition-colors"
title="Settings"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.8}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Settings
</button>
</header>
)
}
+117
View File
@@ -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 (
<aside className="flex flex-col gap-4 w-60 shrink-0 pt-8">
{/* Estimated Completion */}
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1">
Est. Completion
</p>
<p className="text-lg font-bold text-white">
{formattedCompletionDate ?? <span className="text-gray-500 text-sm font-normal">Not calculated</span>}
</p>
</div>
{/* Daytime testing toggle */}
<div>
<div className="flex items-center justify-between">
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-500">
Day Time Testing (Today)
</p>
<button
role="switch"
aria-checked={daytimeEnabled}
onClick={() => onDaytimeEnabledChange(!daytimeEnabled)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
daytimeEnabled ? 'bg-blue-600' : 'bg-gray-600'
}`}
>
<span
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform ${
daytimeEnabled ? 'translate-x-4' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
{/* Top priority */}
<div>
<label className="block text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1.5">
Top Priority
</label>
<textarea
rows={3}
value={topPriority}
onChange={(e) => onTopPriorityChange(e.target.value)}
placeholder="P2PRXAX001, COERXBE002…"
className="w-full bg-gray-900 border border-gray-600 rounded-md px-2.5 py-1.5 text-xs text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500 resize-none font-mono"
/>
</div>
{/* Remake schedule */}
<button
onClick={onRemakeSchedule}
disabled={loading}
className="w-full py-2 text-sm font-semibold text-white bg-blue-600 hover:bg-blue-500 border border-blue-500 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Working…' : 'Remake Schedule'}
</button>
{/* Config for tonight */}
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1.5">
Config for Today
</p>
{tonightConfigRows.length === 0 ? (
<p className="text-xs text-gray-500">No STA placement mapping available for tonight.</p>
) : (
<div className="max-h-52 overflow-y-auto rounded border border-gray-700">
<table className="w-full text-xs">
<thead className="bg-gray-900 sticky top-0">
<tr>
<th className="text-left text-gray-400 font-semibold px-2 py-1.5 border-b border-gray-700">STA</th>
<th className="text-left text-gray-400 font-semibold px-2 py-1.5 border-b border-gray-700">Testpoint</th>
</tr>
</thead>
<tbody>
{tonightConfigRows.map((row) => (
<tr key={row.sta} className="odd:bg-gray-950 even:bg-gray-900/70">
<td className="text-gray-200 font-semibold px-2 py-1.5 border-b border-gray-800">{row.sta}</td>
<td className="text-gray-300 px-2 py-1.5 border-b border-gray-800">{row.testPoints.join(', ')}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</aside>
)
}
+183
View File
@@ -0,0 +1,183 @@
import { useState, useEffect } from 'react'
function Field({ label, hint, children }) {
return (
<div>
<label className="block text-xs font-semibold text-gray-300 mb-1">
{label}
{hint && <span className="ml-1.5 text-gray-500 font-normal">{hint}</span>}
</label>
{children}
</div>
)
}
const INPUT_CLS =
'w-full bg-gray-900 border border-gray-600 rounded-md px-3 py-1.5 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono'
const TEXTAREA_CLS = INPUT_CLS + ' resize-none'
export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
const [form, setForm] = useState({ ...settings })
// Sync if parent settings change while modal is closed
useEffect(() => {
if (!isOpen) setForm({ ...settings })
}, [isOpen, settings])
if (!isOpen) return null
function set(key, value) {
setForm((f) => ({ ...f, [key]: value }))
}
function handleSave() {
onSave(form)
onClose()
}
function handleBackdropClick(e) {
if (e.target === e.currentTarget) onClose()
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={handleBackdropClick}
>
<div className="relative bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-xl max-h-[90vh] flex flex-col">
{/* Modal header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-700">
<h2 className="text-base font-semibold text-white">Settings</h2>
<button
onClick={onClose}
className="text-gray-500 hover:text-white transition-colors"
title="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Scrollable body */}
<div className="flex flex-col gap-5 overflow-y-auto px-5 py-5">
{/* Section: Paths */}
<div>
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
File Paths
</p>
<div className="flex flex-col gap-3">
<Field label="P2P / COE CSV">
<input
type="text"
className={INPUT_CLS}
placeholder="/path/to/p2p_coe_tests.csv"
value={form.p2pCoeCsvPath ?? ''}
onChange={(e) => set('p2pCoeCsvPath', e.target.value)}
/>
</Field>
<Field label="P3P CSV">
<input
type="text"
className={INPUT_CLS}
placeholder="/path/to/p3p_tests.csv"
value={form.p3pCsvPath ?? ''}
onChange={(e) => set('p3pCsvPath', e.target.value)}
/>
</Field>
<Field label="REF Result Directory">
<input
type="text"
className={INPUT_CLS}
placeholder="/path/to/ref/results"
value={form.refResultDir ?? ''}
onChange={(e) => set('refResultDir', e.target.value)}
/>
</Field>
<Field label="DUT Result Directory">
<input
type="text"
className={INPUT_CLS}
placeholder="/path/to/dut/results"
value={form.dutResultDir ?? ''}
onChange={(e) => set('dutResultDir', e.target.value)}
/>
</Field>
</div>
</div>
<hr className="border-gray-700" />
{/* Section: Test Exclusion */}
<div>
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
Test Exclusion
</p>
<Field label="Excluded Test IDs" hint="(comma-separated)">
<input
type="text"
className={INPUT_CLS}
placeholder="P2PRXAX001, COERXBE002…"
value={form.testExclusion ?? ''}
onChange={(e) => set('testExclusion', e.target.value)}
/>
</Field>
</div>
<hr className="border-gray-700" />
{/* Section: Holidays */}
<div>
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
Holidays
</p>
<Field label="Holiday Dates" hint="(comma-separated, YYYY-MM-DD)">
<input
type="text"
className={INPUT_CLS}
placeholder="2026-07-04, 2026-12-25…"
value={form.holidays ?? ''}
onChange={(e) => set('holidays', e.target.value)}
/>
</Field>
</div>
<hr className="border-gray-700" />
{/* Section: Schedule */}
<div>
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
Schedule
</p>
<Field label="Start Date Override" hint="(YYYY-MM-DD, leave blank for today)">
<input
type="date"
className={INPUT_CLS}
value={form.startDateOverride ?? ''}
onChange={(e) => set('startDateOverride', e.target.value)}
/>
</Field>
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-2 px-5 py-4 border-t border-gray-700">
<button
onClick={onClose}
className="px-4 py-1.5 text-sm text-gray-300 bg-gray-800 border border-gray-600 rounded-md hover:bg-gray-700 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-4 py-1.5 text-sm font-semibold text-white bg-blue-600 hover:bg-blue-500 border border-blue-500 rounded-md transition-colors"
>
Save
</button>
</div>
</div>
</div>
)
}
+24
View File
@@ -0,0 +1,24 @@
import TestCard from './TestCard'
export default function ShiftSlot({ label, tests = [], visible = true, active = false }) {
if (!visible) return null
return (
<div className={`border-t pt-1 pb-1.5 ${
active ? 'border-blue-500/50 bg-blue-950/20 rounded' : 'border-gray-700/60'
}`}>
<p className={`text-[10px] font-semibold uppercase tracking-wider mb-1 px-1 ${
active ? 'text-blue-400' : 'text-gray-500'
}`}>
{label}
</p>
<div className="flex flex-col gap-0.5 px-1 min-h-4">
{tests.length === 0 ? (
<span className="text-[10px] text-gray-600 italic"></span>
) : (
tests.map((test) => <TestCard key={`${test.test_id}-${test.device}`} test={test} />)
)}
</div>
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
const STATUS_STYLES = {
pending: 'bg-gray-600/60 text-gray-200 border-gray-500',
completed: 'bg-green-800/60 text-green-200 border-green-600',
failed: 'bg-red-800/60 text-red-200 border-red-600',
invalid: 'bg-yellow-800/60 text-yellow-200 border-yellow-600',
}
export default function TestCard({ test }) {
const style = STATUS_STYLES[test.status] ?? STATUS_STYLES.pending
return (
<div className="relative group">
<div
className={`px-1.5 py-0.5 rounded border text-xs font-mono truncate cursor-default select-none ${style}`}
style={{ maxWidth: '100%' }}
>
{test.test_id}
</div>
{/* Hover tooltip */}
<div className="absolute z-50 bottom-full left-0 mb-1 hidden group-hover:block min-w-max">
<div className="bg-gray-800 border border-gray-600 rounded-md px-2.5 py-1.5 text-xs text-gray-200 shadow-lg">
<p><span className="text-gray-400">Type:</span> {test.test_type ?? '—'}</p>
<p><span className="text-gray-400">Rotation:</span> {test.rotation ?? '—'}</p>
<p><span className="text-gray-400">Device:</span> {test.device ?? '—'}</p>
</div>
</div>
</div>
)
}
+1 -111
View File
@@ -1,111 +1 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body {
margin: 0;
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
@import "tailwindcss";
+10 -1
View File
@@ -1,7 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})