diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c8c9237
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,50 @@
+# Node
+node_modules/
+dist/
+.vite/
+
+# Python
+__pycache__/
+*.py[cod]
+*.pyo
+*.egg-info/
+*.egg
+.eggs/
+*.whl
+.venv/
+venv/
+env/
+
+# Byte-compiled / optimized
+*.so
+
+# Jupyter
+.ipynb_checkpoints/
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+.DS_Store
+Thumbs.db
+
+# Environment
+.env
+.env.local
+.env.*.local
+
+# Logs
+*.log
+npm-debug.log*
+
+# macOS
+__MACOSX/
+
+# Model artifacts (large binaries)
+*.pkl
+
+# Misc
+*.bak
+*.tmp
diff --git a/Dockerfile.backend b/Dockerfile.backend
new file mode 100644
index 0000000..60a5521
--- /dev/null
+++ b/Dockerfile.backend
@@ -0,0 +1,32 @@
+FROM python:3.11-slim
+
+WORKDIR /app
+
+# Copy dependency manifests
+COPY requirements.txt .
+COPY backend/requirements.txt backend/requirements.txt
+COPY earnings/earnings/requirements.txt earnings/earnings/requirements.txt
+COPY drivepulse_stress_model/requirements.txt drivepulse_stress_model/requirements.txt
+
+# Install Python dependencies
+RUN pip install --no-cache-dir -r requirements.txt \
+ && pip install --no-cache-dir -r backend/requirements.txt \
+ && pip install --no-cache-dir -r earnings/earnings/requirements.txt \
+ && pip install --no-cache-dir -r drivepulse_stress_model/requirements.txt
+
+# Copy source code
+COPY backend/ backend/
+COPY earnings/ earnings/
+COPY drivepulse_stress_model/ drivepulse_stress_model/
+
+ENV PYTHONUNBUFFERED=1
+
+EXPOSE 8000
+
+# Match local dev layout: run from /app/backend so imports like `from utils...`
+# and `from data...` resolve the same as `cd backend && python main.py`
+WORKDIR /app/backend
+
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+
+
diff --git a/Dockerfile.frontend b/Dockerfile.frontend
new file mode 100644
index 0000000..7a206bf
--- /dev/null
+++ b/Dockerfile.frontend
@@ -0,0 +1,22 @@
+FROM node:20-alpine AS build
+
+WORKDIR /app
+
+COPY frontend/package*.json ./
+RUN npm ci
+
+COPY frontend/ .
+RUN npm run build
+
+FROM nginx:1.27-alpine
+
+WORKDIR /usr/share/nginx/html
+
+COPY --from=build /app/dist ./
+
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+
+EXPOSE 80
+
+CMD ["nginx", "-g", "daemon off;"]
+
diff --git a/Procfile b/Procfile
new file mode 100644
index 0000000..0a0a6fc
--- /dev/null
+++ b/Procfile
@@ -0,0 +1 @@
+web: uvicorn backend.main:app --host 0.0.0.0 --port ${PORT:-8000}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..637ccbc
--- /dev/null
+++ b/README.md
@@ -0,0 +1,159 @@
+# π DrivePulse
+
+Real-time driver wellness & earnings intelligence platform for ride-hailing drivers. Uses on-device sensor data (accelerometer, gyroscope, microphone) with ML models to detect stressful driving situations and forecast earnings velocity.
+
+---
+
+## Features
+
+- **Dashboard** β Daily trips, earnings, stress score, timeline
+- **Trip Detail** β Map playback, sensor charts, event detection with explainability
+- **Trends** β Weekly/monthly earnings, stress, and velocity charts
+- **Goals** β Set and track daily earnings targets
+- **Predict** β Enter sensor/earnings values β instant ML prediction *(judge-facing)*
+- **Batch Upload** β Upload CSV β run inference on multiple trips at once *(judge-facing)*
+- **Explainability** β Per-event feature contributions, confidence badges
+- **Feedback** β Thumbs up/down on detected events
+- **Auth** β Login / register with demo accounts or new profile
+
+To log **multiple trips at once**, go to the `Trips` tab and use **Import CSV**.
+
+---
+
+## Architecture
+
+```
+Driver-Pulse/
+βββ backend/ # FastAPI REST API (25 endpoints)
+β βββ main.py # Routes, middleware, Pydantic models
+β βββ data/
+β β βββ sample_data.py # Synthetic trip/route/event generator
+β β βββ batch_processor.py # Loads ML models, runs batch inference
+β β βββ trips_import.py # CSV trip import parser
+β β βββ users.py # In-memory auth store
+β β βββ config.py # Batch limits & constants
+β βββ utils/
+β βββ logging.py # Timestamped structured logging
+β
+βββ frontend/ # React 18 + Vite + Tailwind SPA
+β βββ src/
+β βββ pages/ # 8 pages: Home, Dashboard, Trips, TripDetail,
+β β # Trends, Goals, Predict, BatchUpload
+β βββ components/ # 16 reusable components
+β βββ api/client.js # Centralised API client
+β βββ utils/sanityChecks.js # Input validation helpers
+β
+βββ drivepulse_stress_model/ # Stress Detection ML pipeline
+β βββ run.py # CLI entry (--generate --calibrate --train --demo)
+β βββ src/
+β β βββ generate_data.py # Synthetic sensor window generator (3,150 samples)
+β β βββ train.py # RF classifier training + evaluation
+β β βββ inference.py # InferenceEngine with rule-based fallback
+β β βββ hal.py # Hardware Abstraction Layer (device calibration)
+β βββ model/ # Trained artifacts (rf_model.pkl, baselines, contract)
+β βββ calibration/ # Device calibration profile
+β
+βββ earnings/earnings/ # Earnings Forecasting ML pipeline
+β βββ run.py # Sequential pipeline entry
+β βββ src/
+β β βββ build_dataset.py # Merges drivers + goals + velocity + trips
+β β βββ features.py # 14-feature engineering (lags, rolling avg, rush flags)
+β β βββ augment.py # 5Γ Gaussian noise augmentation
+β β βββ train.py # RF regressor training + evaluation
+β β βββ inference.py # Batch velocity prediction
+β βββ model/ # Trained artifacts (rf_model.pkl, contract)
+β βββ data/ # Source CSVs (drivers, goals, velocity, trips)
+β
+βββ streamlit_app.py # Standalone Streamlit demo (3 tabs)
+βββ tests/data/ # Example CSVs for batch & import testing
+βββ requirements.txt # Root Python dependencies
+```
+
+```mermaid
+flowchart LR
+ browser[Browser_ReactApp] --> api[FastAPI_Backend]
+ api --> tripsStore[InMemory_Trips_+_Goals]
+ api --> stressBatch[Stress_Batch_Processor]
+ api --> earningsBatch[Earnings_Batch_Processor]
+ stressBatch --> stressModel[Stress_Model_Files]
+ earningsBatch --> earningsModel[Earnings_Model_Files]
+```
+
+---
+
+## Setup
+
+### Prerequisites
+- Python 3.9+
+- Node.js 18+
+ - Docker Desktop (for judge-friendly containerisation)
+
+### Install & Run (local dev)
+
+```bash
+# Install Python dependencies
+pip install -r requirements.txt
+
+# Start backend (http://localhost:8000)
+cd backend && python main.py
+
+# In a new terminal β start frontend (http://localhost:5173)
+cd frontend && npm install && npm run dev
+```
+
+Open **http://localhost:5173** in your browser.
+
+---
+
+### Run with Docker
+
+With [Docker Desktop](https://www.docker.com/products/docker-desktop/) running:
+
+```bash
+# From the repo root (Driver-Pulse/)
+docker compose up --build
+```
+
+Then open:
+
+- Frontend: `http://localhost:5173`
+- Backend (direct): `http://localhost:8000/api/health`
+
+The frontend talks to the backend via `/api/*`, which is proxied by Nginx inside the `frontend` container to the `backend` container.
+
+**Judge login (demo account):**
+
+- Username: `judge@uber.com`
+- Password: `hackathon2026`
+
+---
+
+## Tech Stack
+
+| Layer | Tech |
+|-------|------|
+| Frontend | React 18, Vite, Tailwind CSS, Recharts, Leaflet |
+| Backend | FastAPI, Uvicorn |
+| ML | scikit-learn, NumPy, Pandas |
+
+---
+
+## Data Flow
+
+- **Trips & goals**: Manual entry or CSV import hit `/api/trips` or `/api/trips/import-csv`, which update an in-memory trips list. Goals (`/api/goals`) and dashboard (`/api/dashboard`) recompute current earnings, hours, and forecast from those trips.
+- **Batch stress & earnings**: Batch CSV uploads are processed by backend helpers that engineer features, call local models, and return per-row predictions plus summaries as JSON.
+
+---
+
+## Scalability & Modularity
+
+- **Backend**: FastAPI routes in `backend/main.py` delegate to small modules in `backend/data/` for trips, goals, imports, and batch processing, so swapping the in-memory store for a database or separate ML service is a local change.
+- **Frontend**: The React app uses a single API client layer (`frontend/src/api/client.js`) plus page/component separation, making it easy to plug in global state, auth, or feature flags without rewriting screens.
+- **Batch endpoints**: Batch CSV processing is stateless per request, so multiple backend instances can handle uploads in parallel behind a load balancer.
+
+---
+
+## Testing & Validation Notes
+
+- **Frontend sanity checks** β lightweight helpers in `frontend/src/utils/sanityChecks.js` validate money inputs, time ranges, and clamp goal targets.
+- **Example test files** β illustrative, non-wired tests live in `frontend/src/__tests__/` (e.g. `EarningsProgress.test.jsx`, `TripsAddTrip.test.jsx`) to show how key components and behaviours could be validated in a full test setup.
diff --git a/backend/data/__init__.py b/backend/data/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/data/batch_processor.py b/backend/data/batch_processor.py
new file mode 100644
index 0000000..3962f78
--- /dev/null
+++ b/backend/data/batch_processor.py
@@ -0,0 +1,443 @@
+"""
+DrivePulse Backend β Batch CSV processing.
+Loads trained stress + earnings models, runs inference on uploaded CSV data.
+"""
+
+import io
+import csv
+import json
+import math
+import numpy as np
+import joblib
+import pandas as pd
+from pathlib import Path
+from typing import Optional
+
+from .config import BATCH_ROW_SOFT_LIMIT
+
+ROOT = Path(__file__).resolve().parent.parent.parent # project root (Driver-Pulse/)
+
+# ββ Stress model ββββββββββββββββββββββββββββββββββββββββββββββ
+
+STRESS_MODEL_DIR = ROOT / "drivepulse_stress_model" / "model"
+
+STRESS_SITUATIONS = {
+ 0: {"name": "NORMAL", "emoji": "β
", "severity": "low"},
+ 1: {"name": "TRAFFIC_STOP", "emoji": "π¦", "severity": "low"},
+ 2: {"name": "SPEED_BREAKER", "emoji": "β οΈ", "severity": "medium"},
+ 3: {"name": "CONFLICT", "emoji": "π ", "severity": "high"},
+ 4: {"name": "ESCALATING", "emoji": "π΄", "severity": "high"},
+ 5: {"name": "ARGUMENT_ONLY", "emoji": "π£οΈ", "severity": "medium"},
+ 6: {"name": "MUSIC_OR_CALL", "emoji": "π΅", "severity": "low"},
+}
+
+NOTIFY_ON = {3, 4, 5}
+SAFETY_ON = {3, 4}
+
+_stress_clf = None
+_stress_mean = None
+_stress_std = None
+_stress_feats = None
+
+
+def _load_stress_model():
+ global _stress_clf, _stress_mean, _stress_std, _stress_feats
+ if _stress_clf is not None:
+ return True
+ try:
+ _stress_clf = joblib.load(STRESS_MODEL_DIR / "rf_model.pkl")
+ _stress_mean = np.load(STRESS_MODEL_DIR / "baseline_mean.npy")
+ _stress_std = np.load(STRESS_MODEL_DIR / "baseline_std.npy")
+ _stress_feats = json.loads(
+ (STRESS_MODEL_DIR / "feature_contract.json").read_text()
+ )["features"]
+ print(f"[batch] Stress model loaded ({len(_stress_feats)} features)")
+ return True
+ except Exception as e:
+ print(f"[batch] Stress model load failed: {e}")
+ return False
+
+
+def predict_stress_row(row: dict) -> dict:
+ """Run stress prediction on a single row dict."""
+ if not _load_stress_model():
+ return _stress_fallback(row)
+
+ x = np.array(
+ [float(row.get(f, 0.0)) for f in _stress_feats], dtype=np.float32
+ ).reshape(1, -1)
+ xn = (x - _stress_mean) / _stress_std
+ pred = int(_stress_clf.predict(xn)[0])
+ proba = _stress_clf.predict_proba(xn)[0]
+ conf = float(proba[pred])
+
+ if conf < 0.50:
+ pred, conf = 0, conf
+
+ sit = STRESS_SITUATIONS[pred]
+ conf_label = "high" if conf >= 0.75 else ("medium" if conf >= 0.50 else "low")
+
+ # Top 3 feature importance (simple absolute deviation approach)
+ deviations = []
+ for i, f in enumerate(_stress_feats):
+ val = float(row.get(f, 0.0))
+ mean_val = float(_stress_mean[i]) if i < len(_stress_mean) else 0
+ std_val = float(_stress_std[i]) if i < len(_stress_std) else 1
+ z = abs((val - mean_val) / max(std_val, 1e-6))
+ deviations.append({"feature": f, "z_score": round(z, 3), "value": round(val, 3)})
+ deviations.sort(key=lambda d: d["z_score"], reverse=True)
+ top3 = deviations[:3]
+
+ return {
+ "situation_id": pred,
+ "situation_name": sit["name"],
+ "emoji": sit["emoji"],
+ "severity": sit["severity"],
+ "confidence": round(conf, 3),
+ "confidence_level": conf_label,
+ "should_notify": pred in NOTIFY_ON and conf_label != "low",
+ "is_safety_critical": pred in SAFETY_ON and conf_label == "high",
+ "top_features": top3,
+ "all_probabilities": {
+ STRESS_SITUATIONS[i]["name"]: round(float(proba[i]), 4)
+ for i in range(len(proba))
+ },
+ }
+
+
+def _stress_fallback(row: dict) -> dict:
+ """Rule-based fallback when model files are missing."""
+ motion = float(row.get("motion_p95", 0))
+ audio = float(row.get("audio_db_p90", 50))
+ speed = float(row.get("speed_mean", 30))
+ lead = float(row.get("audio_leads_motion", 0))
+
+ if motion > 3.5 and audio > 80:
+ pred, conf = (4, 0.60) if lead < -5 else (3, 0.65)
+ elif motion > 3.5 and speed < 20:
+ pred, conf = 2, 0.70
+ elif motion > 3.5:
+ pred, conf = 1, 0.65
+ elif audio > 80:
+ pred, conf = 5, 0.60
+ else:
+ pred, conf = 0, 0.90
+
+ sit = STRESS_SITUATIONS[pred]
+ return {
+ "situation_id": pred,
+ "situation_name": sit["name"],
+ "emoji": sit["emoji"],
+ "severity": sit["severity"],
+ "confidence": round(conf, 3),
+ "confidence_level": "medium",
+ "should_notify": pred in NOTIFY_ON,
+ "is_safety_critical": pred in SAFETY_ON,
+ "top_features": [],
+ "all_probabilities": {},
+ }
+
+
+# ββ Earnings model ββββββββββββββββββββββββββββββββββββββββββββ
+
+EARNINGS_MODEL_DIR = ROOT / "earnings" / "earnings" / "model"
+
+EARNINGS_FEATURES = [
+ "elapsed_hours", "current_velocity", "velocity_delta",
+ "trips_completed", "trip_rate", "hour_of_day",
+ "is_morning_rush", "is_lunch_rush",
+ "velocity_last_1", "velocity_last_2", "velocity_last_3",
+ "rolling_velocity_3", "rolling_velocity_5", "goal_pressure",
+]
+
+_earnings_clf = None
+
+
+def _load_earnings_model():
+ global _earnings_clf
+ if _earnings_clf is not None:
+ return True
+ try:
+ _earnings_clf = joblib.load(EARNINGS_MODEL_DIR / "rf_model.pkl")
+ print(f"[batch] Earnings model loaded")
+ return True
+ except Exception as e:
+ print(f"[batch] Earnings model load failed: {e}")
+ return False
+
+
+def predict_earnings_row(row: dict) -> dict:
+ """Run earnings prediction on a single row dict."""
+ if not _load_earnings_model():
+ return {"predicted_velocity": None, "error": "Model not loaded"}
+
+ x = np.array(
+ [float(row.get(f, 0.0)) for f in EARNINGS_FEATURES], dtype=np.float32
+ ).reshape(1, -1)
+ predicted = float(_earnings_clf.predict(x)[0])
+ predicted = max(0, round(predicted, 2))
+
+ target_velocity = float(row.get("target_velocity", 200))
+ current_velocity = float(row.get("current_velocity", 0))
+ target_earnings = float(row.get("target_earnings", 1800))
+ current_earnings = float(row.get("cumulative_earnings", 0))
+ elapsed = float(row.get("elapsed_hours", 0))
+ remaining_earnings = max(0, target_earnings - current_earnings)
+
+ if predicted > 0:
+ hours_needed = remaining_earnings / predicted
+ else:
+ hours_needed = None
+
+ if predicted >= target_velocity * 1.1:
+ forecast = "ahead"
+ elif predicted >= target_velocity * 0.9:
+ forecast = "on_track"
+ else:
+ forecast = "at_risk"
+
+ return {
+ "predicted_velocity": round(predicted, 2),
+ "target_velocity": target_velocity,
+ "current_velocity": current_velocity,
+ "forecast_status": forecast,
+ "remaining_earnings": round(remaining_earnings, 2),
+ "hours_to_target": round(hours_needed, 2) if hours_needed else None,
+ "pct_target": round(min(100, (current_earnings / max(target_earnings, 1)) * 100), 1),
+ }
+
+
+# ββ Feature engineering for earnings (from raw trip CSV) ββββββ
+
+def engineer_earnings_features(df: pd.DataFrame) -> pd.DataFrame:
+ """
+ Takes a raw trip-level DataFrame and engineers the 14 features
+ needed for the earnings model. Columns expected:
+ driver_id, timestamp, cumulative_earnings, elapsed_hours,
+ current_velocity, target_velocity, velocity_delta,
+ trips_completed, target_earnings
+ """
+ df = df.copy()
+
+ # Parse timestamp β hour info
+ if "timestamp" in df.columns:
+ ts = pd.to_datetime(df["timestamp"], errors="coerce")
+ df["hour_of_day"] = ts.dt.hour.fillna(12).astype(int)
+ elif "hour_of_day" not in df.columns:
+ df["hour_of_day"] = 12
+
+ df["is_morning_rush"] = df["hour_of_day"].between(7, 9).astype(int)
+ df["is_lunch_rush"] = df["hour_of_day"].between(12, 14).astype(int)
+
+ # trip_rate
+ df["elapsed_hours"] = pd.to_numeric(df.get("elapsed_hours", 1), errors="coerce").fillna(1)
+ df["trips_completed"] = pd.to_numeric(df.get("trips_completed", 0), errors="coerce").fillna(0)
+ df["trip_rate"] = df["trips_completed"] / df["elapsed_hours"].replace(0, 1)
+
+ # Velocity lags + rolling
+ df["current_velocity"] = pd.to_numeric(df.get("current_velocity", 0), errors="coerce").fillna(0).clip(0, 600)
+ df["velocity_delta"] = pd.to_numeric(df.get("velocity_delta", 0), errors="coerce").fillna(0)
+ df["target_velocity"] = pd.to_numeric(df.get("target_velocity", 200), errors="coerce").fillna(200)
+
+ df = df.sort_values(["driver_id", "hour_of_day"] if "driver_id" in df.columns else ["hour_of_day"])
+ grp = "driver_id" if "driver_id" in df.columns else None
+
+ if grp:
+ df["velocity_last_1"] = df.groupby(grp)["current_velocity"].shift(1)
+ df["velocity_last_2"] = df.groupby(grp)["current_velocity"].shift(2)
+ df["velocity_last_3"] = df.groupby(grp)["current_velocity"].shift(3)
+ df["rolling_velocity_3"] = df.groupby(grp)["current_velocity"].transform(
+ lambda s: s.rolling(3, min_periods=1).mean()
+ )
+ df["rolling_velocity_5"] = df.groupby(grp)["current_velocity"].transform(
+ lambda s: s.rolling(5, min_periods=1).mean()
+ )
+ else:
+ df["velocity_last_1"] = df["current_velocity"].shift(1)
+ df["velocity_last_2"] = df["current_velocity"].shift(2)
+ df["velocity_last_3"] = df["current_velocity"].shift(3)
+ df["rolling_velocity_3"] = df["current_velocity"].rolling(3, min_periods=1).mean()
+ df["rolling_velocity_5"] = df["current_velocity"].rolling(5, min_periods=1).mean()
+
+ df["goal_pressure"] = df["target_velocity"] - df["current_velocity"]
+
+ # Fill NaNs from shifting
+ df = df.bfill().ffill().fillna(0)
+
+ return df
+
+
+# ββ Batch processing ββββββββββββββββββββββββββββββββββββββββββ
+
+def process_stress_csv(csv_content: str) -> dict:
+ """Process a CSV of sensor windows. Returns per-row predictions + summary."""
+ _load_stress_model()
+
+ reader = csv.DictReader(io.StringIO(csv_content))
+ rows = list(reader)
+
+ if not rows:
+ return {"error": "CSV is empty", "results": [], "summary": {}}
+
+ row_count = len(rows)
+ row_limit_warning = None
+ if BATCH_ROW_SOFT_LIMIT and row_count > BATCH_ROW_SOFT_LIMIT:
+ row_limit_warning = (
+ f"Processed {row_count} rows; consider chunking to {BATCH_ROW_SOFT_LIMIT} rows per upload for large workloads."
+ )
+ results = []
+ severity_counts = {"low": 0, "medium": 0, "high": 0}
+ situation_counts = {}
+
+ for i, row in enumerate(rows):
+ # Add constant features
+ row["motion_std"] = 0.3
+ row["z_dev_max"] = 0.5
+ row["spikes_above3"] = 0
+ row["spikes_above5"] = 0
+ row["audio_class_max"] = 2.0
+ row["audio_class_mean"] = 1.0
+ row["sustained_max"] = 10.0
+ row["sustained_sum"] = 50.0
+ row["cadence_var_max"] = 0.6
+ row["audio_leads_motion"] = 0.0
+ row["audio_onset_sec"] = 0.0
+ row["brake_t_sec"] = 0.0
+ row["is_low_speed"] = 0
+ row["both_elevated"] = 0
+ row["audio_only"] = 0
+ pred = predict_stress_row(row)
+ pred["row_index"] = i
+ # Carry through any extra columns (trip_id, timestamp, etc.)
+ for k in ("trip_id", "window_id", "timestamp", "start_time", "driver_id"):
+ if k in row:
+ pred[k] = row[k]
+
+ results.append(pred)
+ severity_counts[pred["severity"]] = severity_counts.get(pred["severity"], 0) + 1
+ situation_counts[pred["situation_name"]] = situation_counts.get(pred["situation_name"], 0) + 1
+
+ total = len(results)
+ avg_confidence = round(sum(r["confidence"] for r in results) / total, 3)
+ notify_count = sum(1 for r in results if r["should_notify"])
+ safety_count = sum(1 for r in results if r["is_safety_critical"])
+
+ summary = {
+ "total_windows": total,
+ "severity_counts": severity_counts,
+ "situation_counts": situation_counts,
+ "avg_confidence": avg_confidence,
+ "notifications_triggered": notify_count,
+ "safety_critical_count": safety_count,
+ "stress_score": round(
+ (severity_counts.get("high", 0) * 5 + severity_counts.get("medium", 0) * 3)
+ / max(total, 1),
+ 2,
+ ),
+ }
+ if row_limit_warning:
+ summary["row_limit_warning"] = row_limit_warning
+
+ return {
+ "results": results,
+ "summary": summary,
+ }
+
+
+def process_earnings_csv(csv_content: str) -> dict:
+ """Process a CSV of earnings/trip data. Returns per-row velocity predictions + summary."""
+ _load_earnings_model()
+
+ df = pd.read_csv(io.StringIO(csv_content))
+
+ if df.empty:
+ return {"error": "CSV is empty", "results": [], "summary": {}}
+
+ row_count = len(df)
+ row_limit_warning = None
+ if BATCH_ROW_SOFT_LIMIT and row_count > BATCH_ROW_SOFT_LIMIT:
+ row_limit_warning = (
+ f"Processed {row_count} rows; consider chunking to {BATCH_ROW_SOFT_LIMIT} rows per upload for large workloads."
+ )
+
+ # Fill defaults for missing columns
+ for col, default in [
+ ("driver_id", "driver-001"),
+ ("target_velocity", 200),
+ ("velocity_delta", 0),
+ ("target_earnings", 1800),
+ ("cumulative_earnings", 0),
+ ]:
+ if col not in df.columns:
+ df[col] = default
+
+ df = engineer_earnings_features(df)
+
+ results = []
+ total_predicted = 0
+
+ for i, row in df.iterrows():
+ row_dict = row.to_dict()
+ pred = predict_earnings_row(row_dict)
+ pred["row_index"] = int(i)
+ for k in ("driver_id", "timestamp", "trip_id", "hour_of_day"):
+ if k in row_dict:
+ pred[k] = row_dict[k]
+ if isinstance(pred[k], float) and not math.isnan(pred[k]):
+ pred[k] = int(pred[k]) if k == "hour_of_day" else pred[k]
+ elif isinstance(pred[k], float) and math.isnan(pred[k]):
+ pred[k] = None
+
+ pred["cumulative_earnings"] = round(float(row_dict.get("cumulative_earnings", 0)), 2)
+ pred["elapsed_hours"] = round(float(row_dict.get("elapsed_hours", 0)), 2)
+
+ results.append(pred)
+ if pred["predicted_velocity"] is not None:
+ total_predicted += pred["predicted_velocity"]
+
+ total = len(results)
+ avg_velocity = round(total_predicted / max(total, 1), 2)
+
+ forecast_counts = {"ahead": 0, "on_track": 0, "at_risk": 0}
+ for r in results:
+ forecast_counts[r.get("forecast_status", "on_track")] += 1
+
+ summary = {
+ "total_entries": total,
+ "avg_predicted_velocity": avg_velocity,
+ "forecast_counts": forecast_counts,
+ "best_velocity": max((r["predicted_velocity"] for r in results if r["predicted_velocity"]), default=0),
+ "worst_velocity": min((r["predicted_velocity"] for r in results if r["predicted_velocity"]), default=0),
+ }
+ if row_limit_warning:
+ summary["row_limit_warning"] = row_limit_warning
+
+ return {
+ "results": results,
+ "summary": summary,
+ }
+
+
+# ββ Template generators ββββββββββββββββββββββββββββββββββββββ
+
+def stress_csv_template() -> str:
+ """Return a CSV template string with headers + 2 sample rows."""
+ feats = [
+ "motion_max", "motion_mean", "motion_p95", "brake_intensity", "lateral_max",
+ "speed_mean", "speed_at_brake", "speed_drop",
+ "audio_db_max", "audio_db_mean", "audio_db_p90", "audio_db_std",
+ "cadence_var_mean", "argument_frac", "loud_frac",
+ ]
+ header = "trip_id,timestamp," + ",".join(feats)
+ row1 = "trip-001,08:15:00,1.2,0.6,1.1,0.8,0.5,35,35,5,68,62,67,4,0.1,0.15"
+ row2 = "trip-002,09:30:00,5.1,1.8,4.8,4.9,2.1,40,40,25,94,82,91,8,0.72,0.95"
+ return header + "\n" + row1 + "\n" + row2 + "\n"
+
+
+def earnings_csv_template() -> str:
+ """Return a CSV template string for earnings upload."""
+ header = "driver_id,timestamp,cumulative_earnings,elapsed_hours,current_velocity,target_velocity,velocity_delta,trips_completed,target_earnings"
+ row1 = "driver-001,08:00:00,0,0.5,0,200,0,0,1800"
+ row2 = "driver-001,09:00:00,185,1.5,185,200,-15,2,1800"
+ row3 = "driver-001,10:00:00,420,2.5,210,200,10,4,1800"
+ return header + "\n" + row1 + "\n" + row2 + "\n" + row3 + "\n"
diff --git a/backend/data/config.py b/backend/data/config.py
new file mode 100644
index 0000000..7911bef
--- /dev/null
+++ b/backend/data/config.py
@@ -0,0 +1,9 @@
+"""
+Shared configuration and limits for backend demo modules.
+Kept small and simple on purpose so it is easy to swap out in a real deployment.
+"""
+
+# Soft limit for batch CSV rows. Requests above this size are still processed,
+# but responses can include a warning so callers know to chunk work if needed.
+BATCH_ROW_SOFT_LIMIT = 5000
+
diff --git a/backend/data/sample_data.py b/backend/data/sample_data.py
new file mode 100644
index 0000000..e6e68da
--- /dev/null
+++ b/backend/data/sample_data.py
@@ -0,0 +1,535 @@
+"""
+DrivePulse Backend β Sample data generator.
+Produces realistic trips, events, signals, and driver metrics.
+"""
+
+import random
+import math
+import uuid
+from datetime import datetime, timedelta
+from typing import List, Dict, Any
+
+random.seed(42)
+
+# Canonical "today" used across demo endpoints.
+# (The frontend defaults to this date as well.)
+TODAY = datetime(2026, 3, 8)
+TODAY_STR = TODAY.strftime("%Y-%m-%d")
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
+SITUATIONS = {
+ 0: {"name": "NORMAL", "emoji": "β
", "severity": "low"},
+ 1: {"name": "TRAFFIC_STOP", "emoji": "π¦", "severity": "low"},
+ 2: {"name": "SPEED_BREAKER", "emoji": "β οΈ", "severity": "medium"},
+ 3: {"name": "CONFLICT", "emoji": "π ", "severity": "high"},
+ 4: {"name": "ESCALATING", "emoji": "π΄", "severity": "high"},
+ 5: {"name": "ARGUMENT_ONLY", "emoji": "π£οΈ", "severity": "medium"},
+ 6: {"name": "MUSIC_OR_CALL", "emoji": "π΅", "severity": "low"},
+}
+
+STRESS_TIPS = [
+ {"id": 1, "title": "Deep Breathing", "text": "Try 4-7-8 breathing: inhale 4s, hold 7s, exhale 8s. Helps reduce cortisol instantly.", "cta": "Try now"},
+ {"id": 2, "title": "Pull Over Briefly", "text": "If safe, stop for 2 minutes. A short pause resets your stress response.", "cta": "Find safe spot"},
+ {"id": 3, "title": "Adjust Temperature", "text": "Lower the AC by 2Β°. Cooler air helps reduce tension and improve alertness.", "cta": "Got it"},
+ {"id": 4, "title": "Play Calming Audio", "text": "Switch to lo-fi or nature sounds. Reduces heart rate within 3 minutes.", "cta": "Open music"},
+ {"id": 5, "title": "Stretch at Next Stop", "text": "Roll your shoulders and stretch your neck at the next red light or stop.", "cta": "Remind me"},
+ {"id": 6, "title": "Hydrate", "text": "Dehydration increases stress hormones. Take a sip of water.", "cta": "Thanks"},
+ {"id": 7, "title": "Positive Self-Talk", "text": "Remind yourself: 'This moment will pass. I'm doing well today.'", "cta": "Noted"},
+ {"id": 8, "title": "Micro-break", "text": "After this trip, take a 5-minute walk. It clears mental fatigue.", "cta": "Schedule break"},
+]
+
+# Bangalore area coordinates for route generation
+BANGALORE_CENTER = (12.9716, 77.5946)
+ROUTE_ANCHORS = [
+ [(12.9716, 77.5946), (12.9780, 77.6050), (12.9850, 77.6150), (12.9900, 77.6200)],
+ [(12.9350, 77.6140), (12.9400, 77.6050), (12.9500, 77.5970), (12.9600, 77.5900)],
+ [(12.9600, 77.5700), (12.9550, 77.5800), (12.9500, 77.5900), (12.9450, 77.6000)],
+ [(12.9800, 77.5500), (12.9750, 77.5600), (12.9700, 77.5750), (12.9716, 77.5946)],
+ [(12.9200, 77.6200), (12.9300, 77.6100), (12.9400, 77.5950), (12.9500, 77.5850)],
+ [(12.9900, 77.5800), (12.9850, 77.5900), (12.9800, 77.6000), (12.9750, 77.6100)],
+]
+
+
+def _interp_route(anchors: list, n_points: int = 60) -> list:
+ """Interpolate between anchor points to create a smooth route."""
+ route = []
+ segs = len(anchors) - 1
+ pts_per_seg = max(n_points // segs, 2)
+ for i in range(segs):
+ for j in range(pts_per_seg):
+ t = j / pts_per_seg
+ lat = anchors[i][0] + t * (anchors[i + 1][0] - anchors[i][0]) + random.gauss(0, 0.0002)
+ lng = anchors[i][1] + t * (anchors[i + 1][1] - anchors[i][1]) + random.gauss(0, 0.0002)
+ route.append([round(lat, 6), round(lng, 6)])
+ route.append(list(anchors[-1]))
+ return route
+
+
+def _gen_signals(duration_min: int, events: list) -> Dict[str, list]:
+ """Generate time-series signals: speed, accel_magnitude, audio_db."""
+ n = duration_min * 6 # one sample every 10s
+ ts = list(range(0, duration_min * 60, 10))
+ speed = []
+ accel = []
+ audio = []
+
+ base_speed = random.uniform(20, 45)
+ for i in range(n):
+ t_sec = ts[i] if i < len(ts) else i * 10
+ # Base speed with traffic variation
+ s = base_speed + 10 * math.sin(2 * math.pi * i / n) + random.gauss(0, 3)
+ a = abs(random.gauss(1.0, 0.3))
+ d = random.gauss(55, 5)
+
+ # Inject spikes near events
+ for ev in events:
+ ev_sec = ev["offset_sec"]
+ if abs(t_sec - ev_sec) < 30:
+ proximity = 1 - abs(t_sec - ev_sec) / 30
+ if ev["label"] in ("CONFLICT", "ESCALATING", "ARGUMENT_ONLY"):
+ d += 20 * proximity
+ a += 1.5 * proximity
+ elif ev["label"] == "SPEED_BREAKER":
+ a += 3 * proximity
+ s = max(5, s - 15 * proximity)
+ elif ev["label"] == "TRAFFIC_STOP":
+ s = max(0, s - s * proximity)
+
+ speed.append(round(max(0, s), 1))
+ accel.append(round(max(0, a), 2))
+ audio.append(round(max(30, min(100, d)), 1))
+
+ return {"timestamps": ts[:n], "speed": speed, "accel_magnitude": accel, "audio_db": audio}
+
+
+def _gen_events(duration_min: int, stress_level: str) -> list:
+ """Generate random events for a trip based on stress level."""
+ if stress_level == "low":
+ n_events = random.randint(0, 2)
+ pool = [0, 1, 6]
+ elif stress_level == "medium":
+ n_events = random.randint(2, 4)
+ pool = [0, 1, 2, 5, 6]
+ else:
+ n_events = random.randint(3, 6)
+ pool = [1, 2, 3, 4, 5]
+
+ events = []
+ used_offsets = set()
+ for _ in range(n_events):
+ sit_id = random.choice(pool)
+ sit = SITUATIONS[sit_id]
+ offset = random.randint(60, (duration_min - 1) * 60)
+ while any(abs(offset - u) < 30 for u in used_offsets):
+ offset = random.randint(60, (duration_min - 1) * 60)
+ used_offsets.add(offset)
+
+ confidence = round(random.uniform(0.55, 0.98), 2)
+ conf_level = "high" if confidence > 0.85 else ("medium" if confidence > 0.65 else "low")
+
+ # Feature contributions (SHAP-like)
+ all_contribs = [
+ {"feature": "accel_magnitude", "direction": "β", "contribution": round(random.uniform(0.05, 0.35), 3)},
+ {"feature": "audio_db_max", "direction": "β", "contribution": round(random.uniform(0.03, 0.30), 3)},
+ {"feature": "speed_drop", "direction": "β", "contribution": round(random.uniform(0.02, 0.25), 3)},
+ {"feature": "brake_intensity", "direction": "β", "contribution": round(random.uniform(0.01, 0.20), 3)},
+ {"feature": "lateral_max", "direction": "β", "contribution": round(random.uniform(0.01, 0.15), 3)},
+ {"feature": "sustained_max", "direction": "β" if sit_id in (3, 4, 5) else "β", "contribution": round(random.uniform(0.01, 0.18), 3)},
+ {"feature": "cadence_var_mean", "direction": "β" if sit_id in (3, 4) else "β", "contribution": round(random.uniform(0.005, 0.10), 3)},
+ ]
+ all_contribs.sort(key=lambda x: x["contribution"], reverse=True)
+ top3 = all_contribs[:3]
+
+ model_inputs = {
+ "motion_max": round(random.uniform(1, 8), 2),
+ "audio_db_max": round(random.uniform(50, 95), 1),
+ "speed_mean": round(random.uniform(5, 50), 1),
+ "brake_intensity": round(random.uniform(0, 5), 2),
+ "lateral_max": round(random.uniform(0.2, 3), 2),
+ }
+
+ events.append({
+ "id": str(uuid.uuid4())[:8],
+ "offset_sec": offset,
+ "timestamp": None, # filled later
+ "label": sit["name"],
+ "emoji": sit["emoji"],
+ "severity": sit["severity"],
+ "situation_id": sit_id,
+ "confidence": confidence,
+ "confidence_level": conf_level,
+ "explain": {
+ "model_inputs": model_inputs,
+ "top_features": top3,
+ "summary": f"Detected {sit['name'].lower().replace('_', ' ')} β primary driver: {top3[0]['feature']} {top3[0]['direction']}"
+ },
+ "feedback": None,
+ })
+
+ events.sort(key=lambda e: e["offset_sec"])
+ return events
+
+
+def _gen_trip(trip_idx: int, day: datetime, hour: int) -> Dict[str, Any]:
+ """Generate a single trip."""
+ stress_options = ["low", "low", "medium", "medium", "high"]
+ stress_level = random.choice(stress_options)
+
+ duration_min = random.randint(12, 55)
+ start_time = day.replace(hour=hour, minute=random.randint(0, 59), second=0)
+ end_time = start_time + timedelta(minutes=duration_min)
+
+ route_anchors = random.choice(ROUTE_ANCHORS)
+ route = _interp_route(route_anchors, n_points=max(30, duration_min))
+
+ distance_km = round(random.uniform(3, 25), 1)
+ base_fare = 30 + distance_km * 12 + duration_min * 2
+ surge = round(random.choice([1.0, 1.0, 1.0, 1.2, 1.5, 1.8, 2.0]), 1)
+ fare = round(base_fare * surge, 0)
+
+ events = _gen_events(duration_min, stress_level)
+ stress_score = 0
+ for ev in events:
+ w = {"low": 1, "medium": 3, "high": 5}.get(ev["severity"], 1)
+ stress_score += w * ev["confidence"]
+ stress_score = round(min(10, stress_score), 1)
+
+ # Fill event timestamps
+ for ev in events:
+ ev["timestamp"] = (start_time + timedelta(seconds=ev["offset_sec"])).isoformat()
+
+ signals = _gen_signals(duration_min, events)
+
+ # Place event markers on route
+ for ev in events:
+ frac = ev["offset_sec"] / (duration_min * 60)
+ idx = min(int(frac * len(route)), len(route) - 1)
+ ev["location"] = route[idx]
+
+ trip_id = f"trip-{trip_idx:03d}"
+ return {
+ "id": trip_id,
+ "date": day.strftime("%Y-%m-%d"),
+ "start_time": start_time.isoformat(),
+ "end_time": end_time.isoformat(),
+ "duration_min": duration_min,
+ "distance_km": distance_km,
+ "fare": fare,
+ "surge_multiplier": surge,
+ "stress_score": stress_score,
+ "stress_level": stress_level,
+ "events_count": len(events),
+ "events": events,
+ "route": route,
+ "pickup": route[0],
+ "dropoff": route[-1],
+ "signals": signals,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Public generators
+# ---------------------------------------------------------------------------
+
+def generate_trips(today: datetime = None) -> List[Dict]:
+ """Generate ~8-10 trips for today and ~8-10 for yesterday."""
+ if today is None:
+ today = datetime(2026, 3, 8)
+ yesterday = today - timedelta(days=1)
+
+ trips = []
+ idx = 1
+
+ for day, label in [(today, "today"), (yesterday, "yesterday")]:
+ n_trips = random.randint(7, 10)
+ hours = sorted(random.sample(range(6, 23), n_trips))
+ for h in hours:
+ trips.append(_gen_trip(idx, day, h))
+ idx += 1
+
+ return trips
+
+
+def generate_driver_profile() -> Dict:
+ return {
+ "id": "driver-001",
+ "name": "Ravi Kumar",
+ "city": "Bangalore",
+ "rating": 4.82,
+ "experience_months": 18,
+ "avg_hours_per_day": 10,
+ "avg_earnings_per_hour": 185,
+ "shift_preference": "morning",
+ }
+
+
+def generate_goals() -> Dict:
+ return {
+ "daily_target": 1800,
+ "current_earnings": 1230,
+ "target_hours": 10,
+ "current_hours": 6.5,
+ "trips_completed": 8,
+ "forecast_status": "on_track",
+ "goal_probability": 0.78,
+ "required_velocity": 220,
+ "current_velocity": 189,
+ }
+
+
+def build_dashboard(trips: List[Dict], goals: Dict) -> Dict:
+ """Build dashboard summary from trips and goals."""
+ today_trips = [t for t in trips if t["date"] == TODAY_STR]
+
+ total_earnings = sum(t["fare"] for t in today_trips)
+ total_hours = round(sum(t["duration_min"] for t in today_trips) / 60, 1)
+ stress_events = sum(1 for t in today_trips for e in t["events"] if e["severity"] in ("medium", "high"))
+ high_stress_count = sum(1 for t in today_trips for e in t["events"] if e["severity"] == "high")
+ pct_target = round(min(100, (total_earnings / goals["daily_target"]) * 100), 1) if goals["daily_target"] else 0
+
+ return {
+ "date": TODAY_STR,
+ "total_trips": len(today_trips),
+ "total_hours": total_hours,
+ "total_earnings": total_earnings,
+ "stress_events": stress_events,
+ "high_stress_events": high_stress_count,
+ "pct_target_achieved": pct_target,
+ "avg_stress_score": round(sum(t["stress_score"] for t in today_trips) / max(len(today_trips), 1), 1),
+ "earnings_velocity": goals["current_velocity"],
+ }
+
+
+def build_weekly_metrics(trips: List[Dict]) -> Dict:
+ """Build aggregated metrics for trends page."""
+ today = TODAY
+ days = []
+ for i in range(6, -1, -1):
+ day = today - timedelta(days=i)
+ day_str = day.strftime("%Y-%m-%d")
+ day_label = day.strftime("%a")
+ day_trips = [t for t in trips if t["date"] == day_str]
+
+ # For days without data, simulate
+ if not day_trips:
+ n = random.randint(5, 10)
+ earnings = round(random.uniform(1200, 2200), 0)
+ hours = round(random.uniform(6, 11), 1)
+ stress = round(random.uniform(2, 6), 1)
+ stress_events = random.randint(2, 8)
+ else:
+ n = len(day_trips)
+ earnings = sum(t["fare"] for t in day_trips)
+ hours = round(sum(t["duration_min"] for t in day_trips) / 60, 1)
+ stress = round(sum(t["stress_score"] for t in day_trips) / n, 1)
+ stress_events = sum(1 for t in day_trips for e in t["events"] if e["severity"] in ("medium", "high"))
+
+ days.append({
+ "date": day_str,
+ "day": day_label,
+ "trips": n,
+ "earnings": earnings,
+ "hours": hours,
+ "avg_stress": stress,
+ "stress_events": stress_events,
+ "velocity": round(earnings / max(hours, 1), 0),
+ })
+
+ return {
+ "range": "7d",
+ "days": days,
+ "summary": {
+ "avg_daily_earnings": round(sum(d["earnings"] for d in days) / 7, 0),
+ "avg_daily_trips": round(sum(d["trips"] for d in days) / 7, 1),
+ "avg_stress": round(sum(d["avg_stress"] for d in days) / 7, 1),
+ "total_earnings": sum(d["earnings"] for d in days),
+ "total_trips": sum(d["trips"] for d in days),
+ "best_day": max(days, key=lambda d: d["earnings"])["day"],
+ },
+ }
+
+
+def build_monthly_metrics() -> Dict:
+ """Build 30-day metric trend."""
+ today = TODAY
+ days = []
+ for i in range(29, -1, -1):
+ day = today - timedelta(days=i)
+ n = random.randint(5, 12)
+ earnings = round(random.uniform(1000, 2500), 0)
+ hours = round(random.uniform(5, 12), 1)
+ days.append({
+ "date": day.strftime("%Y-%m-%d"),
+ "day": day.strftime("%d %b"),
+ "trips": n,
+ "earnings": earnings,
+ "hours": hours,
+ "avg_stress": round(random.uniform(1.5, 7), 1),
+ "stress_events": random.randint(0, 10),
+ "velocity": round(earnings / max(hours, 1), 0),
+ })
+ return {
+ "range": "30d",
+ "days": days,
+ "summary": {
+ "avg_daily_earnings": round(sum(d["earnings"] for d in days) / 30, 0),
+ "avg_daily_trips": round(sum(d["trips"] for d in days) / 30, 1),
+ "avg_stress": round(sum(d["avg_stress"] for d in days) / 30, 1),
+ "total_earnings": sum(d["earnings"] for d in days),
+ "total_trips": sum(d["trips"] for d in days),
+ },
+ }
+
+
+# ---------------------------------------------------------------------------
+# Singleton data store
+# ---------------------------------------------------------------------------
+
+_TRIPS = None
+_PROFILE = None
+_GOALS = None
+
+
+def get_trips():
+ global _TRIPS
+ if _TRIPS is None:
+ _TRIPS = generate_trips()
+ return _TRIPS
+
+
+def create_user_trip(
+ *,
+ date: str,
+ start_time_iso: str,
+ end_time_iso: str,
+ duration_min: int,
+ distance_km: float,
+ fare: float,
+ stress_score: float = 0.0,
+):
+ """Create a minimal, UI-compatible trip for manual entry/import flows."""
+ # Keep detail page working by providing empty events + synthetic route/signals.
+ events = []
+ route_anchors = ROUTE_ANCHORS[0]
+ route = _interp_route(route_anchors, n_points=max(30, duration_min))
+ signals = _gen_signals(duration_min, events)
+
+ stress_score = float(stress_score or 0.0)
+ # Derive stress_level for existing UI (dots/filters).
+ if stress_score <= 3:
+ stress_level = "low"
+ elif stress_score <= 6:
+ stress_level = "medium"
+ else:
+ stress_level = "high"
+
+ return {
+ "id": f"user-{str(uuid.uuid4())[:8]}",
+ "date": date,
+ "start_time": start_time_iso,
+ "end_time": end_time_iso,
+ "duration_min": int(duration_min),
+ "distance_km": float(distance_km),
+ "fare": float(fare),
+ "surge_multiplier": 1.0,
+ "stress_score": round(min(10.0, max(0.0, stress_score)), 1),
+ "stress_level": stress_level,
+ "events_count": 0,
+ "events": events,
+ "route": route,
+ "pickup": route[0] if route else None,
+ "dropoff": route[-1] if route else None,
+ "signals": signals,
+ }
+
+
+def add_trip(trip: Dict[str, Any]) -> Dict[str, Any]:
+ trips = get_trips()
+ trips.insert(0, trip)
+ return trip
+
+
+def get_profile():
+ global _PROFILE
+ if _PROFILE is None:
+ _PROFILE = generate_driver_profile()
+ return _PROFILE
+
+
+def get_goals():
+ global _GOALS
+ if _GOALS is None:
+ _GOALS = generate_goals()
+ # Always derive "current" metrics from trips so UI stays consistent after add/import.
+ goals = _GOALS
+ trips = get_trips()
+ today_trips = [t for t in trips if t.get("date") == TODAY_STR]
+ current_earnings = round(sum(float(t.get("fare", 0) or 0) for t in today_trips), 2)
+ current_hours = round(sum(float(t.get("duration_min", 0) or 0) for t in today_trips) / 60, 2)
+ trips_completed = len(today_trips)
+ current_velocity = round(current_earnings / current_hours, 0) if current_hours > 0 else 0
+
+ goals["current_earnings"] = current_earnings
+ goals["current_hours"] = current_hours
+ goals["trips_completed"] = trips_completed
+ goals["current_velocity"] = current_velocity
+
+ _recompute_goal_derivatives(goals)
+ _GOALS = goals
+ return goals
+
+
+def set_goal_target(target: float):
+ global _GOALS
+ goals = get_goals()
+ goals["daily_target"] = float(target)
+
+ _recompute_goal_derivatives(goals)
+
+ _GOALS = goals
+ return _GOALS
+
+
+def _recompute_goal_derivatives(goals: Dict[str, Any]) -> None:
+ """
+ Keep all goal-derived fields (remaining earnings/hours, required velocity,
+ probability and forecast status) in one place so they stay consistent
+ between get_goals() and set_goal_target().
+ """
+ remaining_earnings = max(0, float(goals.get("daily_target") or 0) - float(goals.get("current_earnings") or 0))
+ remaining_hours = max(0.1, float(goals.get("target_hours") or 0) - float(goals.get("current_hours") or 0))
+ goals["required_velocity"] = round(remaining_earnings / remaining_hours, 0) if goals.get("daily_target") else 0
+ goals["remaining_earnings"] = round(remaining_earnings, 2)
+ goals["remaining_hours"] = round(
+ max(0.0, float(goals.get("target_hours") or 0) - float(goals.get("current_hours") or 0)),
+ 2,
+ )
+ current_velocity = float(goals.get("current_velocity") or 0)
+ goals["hours_to_target"] = round(remaining_earnings / current_velocity, 2) if current_velocity > 0 else None
+
+ velocity_ratio = goals["current_velocity"] / max(goals["required_velocity"], 1)
+ time_ratio = goals["current_hours"] / max(goals["target_hours"], 0.1)
+
+ if velocity_ratio >= 1.2:
+ prob = 0.95
+ elif velocity_ratio >= 1.0:
+ prob = 0.75
+ elif velocity_ratio >= 0.8:
+ prob = 0.55
+ else:
+ prob = 0.25
+
+ if time_ratio > 0.8:
+ prob *= 0.8
+
+ goals["goal_probability"] = round(min(0.99, max(0.01, prob)), 2)
+
+ if goals["current_velocity"] >= goals["required_velocity"] * 1.1:
+ goals["forecast_status"] = "ahead"
+ elif goals["current_velocity"] >= goals["required_velocity"] * 0.9:
+ goals["forecast_status"] = "on_track"
+ else:
+ goals["forecast_status"] = "at_risk"
diff --git a/backend/data/trips_import.py b/backend/data/trips_import.py
new file mode 100644
index 0000000..b6b280d
--- /dev/null
+++ b/backend/data/trips_import.py
@@ -0,0 +1,112 @@
+import csv
+from datetime import datetime
+from typing import Dict, Any, List, Optional, Tuple
+
+from .sample_data import create_user_trip, add_trip
+
+
+REQUIRED_COLUMNS = ["date", "start_time", "end_time", "distance_km", "fare"]
+OPTIONAL_COLUMNS = ["stress_score"]
+
+
+def trips_csv_template() -> str:
+ header = REQUIRED_COLUMNS + OPTIONAL_COLUMNS
+ example_rows = [
+ {
+ "date": "2026-03-08",
+ "start_time": "09:15",
+ "end_time": "09:45",
+ "distance_km": "8.2",
+ "fare": "310",
+ "stress_score": "2.5",
+ },
+ {
+ "date": "2026-03-08",
+ "start_time": "18:05",
+ "end_time": "18:40",
+ "distance_km": "11.6",
+ "fare": "520",
+ "stress_score": "6.3",
+ },
+ ]
+ lines = [",".join(header)]
+ for r in example_rows:
+ lines.append(",".join(str(r.get(k, "")) for k in header))
+ return "\n".join(lines) + "\n"
+
+
+def _parse_dt(date: str, dt_or_hhmm: str) -> datetime:
+ s = (dt_or_hhmm or "").strip()
+ if "T" in s:
+ return datetime.fromisoformat(s)
+ hh, mm = s.split(":")
+ return datetime.fromisoformat(f"{date}T{int(hh):02d}:{int(mm):02d}:00")
+
+
+def import_trips_csv(csv_content: str) -> Dict[str, Any]:
+ """
+ Parse a Trips CSV and append trips to the in-memory store.
+ Returns summary + per-row results (created trip IDs or errors).
+ """
+ reader = csv.DictReader(csv_content.splitlines())
+ if not reader.fieldnames:
+ return {"error": "CSV missing header row"}
+
+ missing = [c for c in REQUIRED_COLUMNS if c not in reader.fieldnames]
+ if missing:
+ return {"error": f"Missing required columns: {', '.join(missing)}"}
+
+ results: List[Dict[str, Any]] = []
+ created: List[Dict[str, Any]] = []
+
+ for idx, row in enumerate(reader):
+ try:
+ date = (row.get("date") or "").strip()
+ if not date:
+ raise ValueError("date is required")
+
+ start_dt = _parse_dt(date, row.get("start_time"))
+ end_dt = _parse_dt(date, row.get("end_time"))
+ if end_dt <= start_dt:
+ raise ValueError("end_time must be after start_time")
+
+ duration_min = int(round((end_dt - start_dt).total_seconds() / 60))
+ if duration_min <= 0:
+ raise ValueError("duration must be at least 1 minute")
+
+ distance_km = float(row.get("distance_km"))
+ fare = float(row.get("fare"))
+
+ stress_score_raw = (row.get("stress_score") or "").strip()
+ if stress_score_raw == "":
+ stress_score = 0.0
+ else:
+ stress_score = float(stress_score_raw)
+ if stress_score < 0 or stress_score > 10:
+ raise ValueError("stress_score must be between 0 and 10")
+
+ trip = create_user_trip(
+ date=date,
+ start_time_iso=start_dt.isoformat(),
+ end_time_iso=end_dt.isoformat(),
+ duration_min=duration_min,
+ distance_km=distance_km,
+ fare=fare,
+ stress_score=stress_score,
+ )
+ add_trip(trip)
+ created.append(trip)
+ results.append({"row_index": idx, "status": "created", "trip_id": trip["id"]})
+ except Exception as e:
+ results.append({"row_index": idx, "status": "error", "error": str(e)})
+
+ return {
+ "summary": {
+ "total_rows": len(results),
+ "created": sum(1 for r in results if r["status"] == "created"),
+ "errors": sum(1 for r in results if r["status"] == "error"),
+ },
+ "results": results,
+ "created_trips": created,
+ }
+
diff --git a/backend/data/users.py b/backend/data/users.py
new file mode 100644
index 0000000..f6eaac2
--- /dev/null
+++ b/backend/data/users.py
@@ -0,0 +1,146 @@
+"""
+DrivePulse Backend β User authentication and driver profiles.
+Manages login, registration, and driver data.
+"""
+
+import json
+from datetime import datetime
+from typing import Optional, Dict
+
+# In-memory user store (in production, use a real database)
+_USERS = {
+ "alex.kumar": {
+ "driver_id": "DRV001",
+ "password": "password123", # In production, use bcrypt
+ "name": "Alex Kumar",
+ "email": "alex.kumar@example.com",
+ "phone": "+91 9876543210",
+ "city": "Mumbai",
+ "vehicle_type": "Sedan",
+ "vehicle_number": "MH01AB1234",
+ "shift_preference": "morning",
+ "avg_hours_per_day": 7.5,
+ "avg_earnings_per_hour": 185,
+ "experience_months": 18,
+ "rating": 4.8,
+ "total_trips": 342,
+ "total_earnings": 48000,
+ },
+ "priya.singh": {
+ "driver_id": "DRV002",
+ "password": "password123",
+ "name": "Priya Singh",
+ "email": "priya.singh@example.com",
+ "phone": "+91 9876543211",
+ "city": "Bangalore",
+ "vehicle_type": "SUV",
+ "vehicle_number": "KA01CD5678",
+ "shift_preference": "evening",
+ "avg_hours_per_day": 8.2,
+ "avg_earnings_per_hour": 195,
+ "experience_months": 24,
+ "rating": 4.9,
+ "total_trips": 456,
+ "total_earnings": 62000,
+ },
+}
+
+
+def register_user(username: str, password: str, driver_data: dict) -> Optional[Dict]:
+ """Register a new user with driver profile."""
+ if username in _USERS:
+ return None # User already exists
+
+ user = {
+ "driver_id": driver_data.get("driver_id", f"DRV{len(_USERS) + 1000}"),
+ "password": password, # In production, use bcrypt.hashpw()
+ "name": driver_data.get("name", ""),
+ "email": driver_data.get("email", ""),
+ "phone": driver_data.get("phone", ""),
+ "city": driver_data.get("city", ""),
+ "vehicle_type": driver_data.get("vehicle_type", ""),
+ "vehicle_number": driver_data.get("vehicle_number", ""),
+ "shift_preference": driver_data.get("shift_preference", "morning"),
+ "avg_hours_per_day": driver_data.get("avg_hours_per_day", 7.0),
+ "avg_earnings_per_hour": driver_data.get("avg_earnings_per_hour", 180),
+ "experience_months": driver_data.get("experience_months", 0),
+ "rating": driver_data.get("rating", 4.5),
+ "total_trips": driver_data.get("total_trips", 0),
+ "total_earnings": driver_data.get("total_earnings", 0),
+ }
+
+ _USERS[username] = user
+ return {
+ "driver_id": user["driver_id"],
+ "name": user["name"],
+ "email": user["email"],
+ "city": user["city"],
+ "rating": user["rating"],
+ "vehicle_type": user["vehicle_type"],
+ }
+
+
+def login_user(username: str, password: str) -> Optional[Dict]:
+ """Authenticate a user and return their profile."""
+ if username not in _USERS:
+ return None
+
+ user = _USERS[username]
+
+ # Simple password check (in production, use bcrypt.checkpw())
+ if user["password"] != password:
+ return None
+
+ return {
+ "driver_id": user["driver_id"],
+ "username": username,
+ "name": user["name"],
+ "email": user["email"],
+ "phone": user["phone"],
+ "city": user["city"],
+ "vehicle_type": user["vehicle_type"],
+ "vehicle_number": user["vehicle_number"],
+ "rating": user["rating"],
+ "total_trips": user["total_trips"],
+ "total_earnings": user["total_earnings"],
+ "avg_hours_per_day": user["avg_hours_per_day"],
+ "avg_earnings_per_hour": user["avg_earnings_per_hour"],
+ "experience_months": user["experience_months"],
+ "shift_preference": user["shift_preference"],
+ }
+
+
+def get_user_profile(driver_id: str) -> Optional[Dict]:
+ """Get user profile by driver ID."""
+ for user in _USERS.values():
+ if user["driver_id"] == driver_id:
+ return {
+ "driver_id": user["driver_id"],
+ "name": user["name"],
+ "email": user["email"],
+ "phone": user["phone"],
+ "city": user["city"],
+ "vehicle_type": user["vehicle_type"],
+ "vehicle_number": user["vehicle_number"],
+ "rating": user["rating"],
+ "total_trips": user["total_trips"],
+ "total_earnings": user["total_earnings"],
+ "avg_hours_per_day": user["avg_hours_per_day"],
+ "avg_earnings_per_hour": user["avg_earnings_per_hour"],
+ "experience_months": user["experience_months"],
+ "shift_preference": user["shift_preference"],
+ }
+ return None
+
+
+def list_all_users() -> list:
+ """List all available users for demo purposes."""
+ return [
+ {
+ "username": username,
+ "name": user["name"],
+ "city": user["city"],
+ "rating": user["rating"],
+ }
+ for username, user in _USERS.items()
+ ]
diff --git a/backend/main.py b/backend/main.py
new file mode 100644
index 0000000..d264abe
--- /dev/null
+++ b/backend/main.py
@@ -0,0 +1,490 @@
+"""
+DrivePulse Backend β FastAPI application.
+Serves trip data, events, metrics, goals, and stress tips.
+"""
+
+from fastapi import FastAPI, HTTPException, Query, UploadFile, File
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import PlainTextResponse
+from pydantic import BaseModel
+from typing import Optional, List
+from datetime import datetime
+
+from utils.logging import log_info, log_warn
+
+from data.sample_data import (
+ get_trips, get_profile, get_goals, set_goal_target,
+ build_dashboard, build_weekly_metrics, build_monthly_metrics,
+ STRESS_TIPS, SITUATIONS,
+ create_user_trip, add_trip,
+)
+from data.batch_processor import (
+ process_stress_csv, process_earnings_csv,
+ stress_csv_template, earnings_csv_template,
+ predict_stress_row, predict_earnings_row,
+)
+from data.trips_import import import_trips_csv, trips_csv_template
+from data.users import (
+ login_user, register_user, get_user_profile, list_all_users,
+)
+
+app = FastAPI(title="DrivePulse API", version="1.0.0")
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+# ββ Feedback storage (in-memory) ββββββββββββββββββββββββββββββββββββββββββ
+
+_feedback_store: dict = {}
+
+
+class FeedbackPayload(BaseModel):
+ label: str # "correct" | "incorrect" | "not_relevant"
+ comment: Optional[str] = None
+
+
+class GoalPayload(BaseModel):
+ daily_target: float
+
+
+class LoginPayload(BaseModel):
+ username: str
+ password: str
+
+
+class RegisterPayload(BaseModel):
+ username: str
+ password: str
+ name: str
+ email: str
+ phone: str
+ city: str
+ vehicle_type: str
+ vehicle_number: str
+ shift_preference: str = "morning"
+ avg_hours_per_day: float = 7.0
+ avg_earnings_per_hour: float = 180
+ experience_months: int = 0
+
+
+class TripCreatePayload(BaseModel):
+ date: str # YYYY-MM-DD
+ start_time: str # "HH:MM" (local) or ISO datetime
+ end_time: str # "HH:MM" (local) or ISO datetime
+ distance_km: float
+ fare: float
+ stress_score: Optional[float] = None # 0-10 (optional)
+
+
+# ββ Routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+@app.get("/api/health")
+def health():
+ log_info("health check")
+ return {"status": "ok", "timestamp": datetime.now().isoformat()}
+
+
+# ββ Authentication βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+@app.post("/api/auth/login")
+def login(payload: LoginPayload):
+ """Authenticate a driver and return their profile."""
+ user = login_user(payload.username, payload.password)
+ if not user:
+ raise HTTPException(401, "Invalid username or password")
+ return user
+
+
+@app.post("/api/auth/register")
+def register(payload: RegisterPayload):
+ """Register a new driver account."""
+ driver_data = {
+ "name": payload.name,
+ "email": payload.email,
+ "phone": payload.phone,
+ "city": payload.city,
+ "vehicle_type": payload.vehicle_type,
+ "vehicle_number": payload.vehicle_number,
+ "shift_preference": payload.shift_preference,
+ "avg_hours_per_day": payload.avg_hours_per_day,
+ "avg_earnings_per_hour": payload.avg_earnings_per_hour,
+ "experience_months": payload.experience_months,
+ }
+ result = register_user(payload.username, payload.password, driver_data)
+ if not result:
+ raise HTTPException(400, "Username already exists")
+ return result
+
+
+@app.get("/api/auth/users")
+def list_users():
+ """List all available demo users."""
+ return list_all_users()
+
+
+@app.get("/api/profile")
+def profile():
+ return get_profile()
+
+
+# ββ Dashboard βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+@app.get("/api/dashboard")
+def dashboard():
+ trips = get_trips()
+ goals = get_goals()
+ return build_dashboard(trips, goals)
+
+
+# ββ Trips βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+@app.get("/api/trips/template", response_class=PlainTextResponse)
+def trips_template():
+ """Download a CSV template for trips import."""
+ return PlainTextResponse(
+ content=trips_csv_template(),
+ media_type="text/csv",
+ headers={"Content-Disposition": "attachment; filename=trips_template.csv"},
+ )
+
+
+@app.post("/api/trips")
+def create_trip(payload: TripCreatePayload):
+ """Create a single trip (manual entry) and persist in the in-memory store."""
+ try:
+ date = payload.date
+
+ def _to_iso(dt_or_hhmm: str) -> datetime:
+ s = (dt_or_hhmm or "").strip()
+ # Accept ISO datetime if provided
+ if "T" in s:
+ return datetime.fromisoformat(s)
+ # Accept HH:MM, combine with date
+ hh, mm = s.split(":")
+ return datetime.fromisoformat(f"{date}T{int(hh):02d}:{int(mm):02d}:00")
+
+ start_dt = _to_iso(payload.start_time)
+ end_dt = _to_iso(payload.end_time)
+ if end_dt <= start_dt:
+ raise HTTPException(400, "end_time must be after start_time")
+
+ duration_min = int(round((end_dt - start_dt).total_seconds() / 60))
+ if duration_min <= 0:
+ raise HTTPException(400, "Trip duration must be at least 1 minute")
+
+ stress_score = payload.stress_score
+ if stress_score is None:
+ stress_score = 0.0
+ try:
+ stress_score = float(stress_score)
+ except Exception:
+ raise HTTPException(400, "stress_score must be a number between 0 and 10")
+ if stress_score < 0 or stress_score > 10:
+ raise HTTPException(400, "stress_score must be between 0 and 10")
+
+ trip = create_user_trip(
+ date=date,
+ start_time_iso=start_dt.isoformat(),
+ end_time_iso=end_dt.isoformat(),
+ duration_min=duration_min,
+ distance_km=payload.distance_km,
+ fare=payload.fare,
+ stress_score=stress_score,
+ )
+ add_trip(trip)
+ return trip
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(400, str(e))
+
+
+@app.get("/api/trips")
+def list_trips(
+ date: Optional[str] = Query(None, description="YYYY-MM-DD"),
+ stress: Optional[str] = Query(None, description="high | medium | low | any"),
+ earnings_min: Optional[float] = Query(None),
+ earnings_max: Optional[float] = Query(None),
+ time_of_day: Optional[str] = Query(None, description="morning|afternoon|evening|night"),
+ duration_min: Optional[int] = Query(None),
+ duration_max: Optional[int] = Query(None),
+ preset: Optional[str] = Query(None, description="high_stress|high_earnings|night|short"),
+):
+ trips = get_trips()
+
+ # Presets
+ if preset == "high_stress":
+ stress = "high"
+ elif preset == "high_earnings":
+ earnings_min = 500
+ elif preset == "night":
+ time_of_day = "night"
+ elif preset == "short":
+ duration_max = 15
+
+ if date:
+ trips = [t for t in trips if t["date"] == date]
+ if stress and stress != "any":
+ trips = [t for t in trips if t["stress_level"] == stress]
+ if earnings_min is not None:
+ trips = [t for t in trips if t["fare"] >= earnings_min]
+ if earnings_max is not None:
+ trips = [t for t in trips if t["fare"] <= earnings_max]
+ if duration_min is not None:
+ trips = [t for t in trips if t["duration_min"] >= duration_min]
+ if duration_max is not None:
+ trips = [t for t in trips if t["duration_min"] <= duration_max]
+ if time_of_day:
+ def _in_tod(t):
+ h = datetime.fromisoformat(t["start_time"]).hour
+ if time_of_day == "morning":
+ return 5 <= h < 12
+ elif time_of_day == "afternoon":
+ return 12 <= h < 17
+ elif time_of_day == "evening":
+ return 17 <= h < 21
+ elif time_of_day == "night":
+ return h >= 21 or h < 5
+ return True
+ trips = [t for t in trips if _in_tod(t)]
+
+ # Strip heavy data from list view
+ lite = []
+ for t in trips:
+ lite.append({k: v for k, v in t.items() if k not in ("signals", "route", "events")})
+ lite[-1]["events_summary"] = [
+ {"label": e["label"], "severity": e["severity"]}
+ for e in t["events"]
+ ]
+ return {"trips": lite, "count": len(lite)}
+
+
+@app.get("/api/trips/{trip_id}")
+def get_trip(trip_id: str):
+ trips = get_trips()
+ trip = next((t for t in trips if t["id"] == trip_id), None)
+ if not trip:
+ raise HTTPException(404, "Trip not found")
+ # Attach feedback if any
+ for ev in trip["events"]:
+ ev["feedback"] = _feedback_store.get(ev["id"])
+ return trip
+
+
+@app.get("/api/sample-trip")
+def sample_trip():
+ """Return a feature-rich sample trip for judges / quick start."""
+ trips = get_trips()
+ # Pick the trip with the most events
+ best = max(trips, key=lambda t: len(t["events"]))
+ return best
+
+
+# ββ Events & Feedback βββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+@app.get("/api/trips/{trip_id}/events")
+def trip_events(trip_id: str):
+ trips = get_trips()
+ trip = next((t for t in trips if t["id"] == trip_id), None)
+ if not trip:
+ raise HTTPException(404, "Trip not found")
+ events = trip["events"]
+ for ev in events:
+ ev["feedback"] = _feedback_store.get(ev["id"])
+ return {"trip_id": trip_id, "events": events}
+
+
+@app.post("/api/events/{event_id}/feedback")
+def post_feedback(event_id: str, payload: FeedbackPayload):
+ _feedback_store[event_id] = {"label": payload.label, "comment": payload.comment}
+ return {"status": "ok", "event_id": event_id, "feedback": _feedback_store[event_id]}
+
+
+# ββ Goals βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+@app.get("/api/goals")
+def goals():
+ return get_goals()
+
+
+@app.post("/api/goals")
+def update_goal(payload: GoalPayload):
+ return set_goal_target(payload.daily_target)
+
+
+# ββ Metrics / Trends ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+@app.get("/api/metrics")
+def metrics(range: str = Query("7d", description="7d | 30d")):
+ if range == "30d":
+ return build_monthly_metrics()
+ return build_weekly_metrics(get_trips())
+
+
+# ββ Stress Tips βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+@app.get("/api/tips")
+def tips(severity: Optional[str] = Query(None)):
+ import random as _r
+ selected = _r.sample(STRESS_TIPS, min(3, len(STRESS_TIPS)))
+ return {"tips": selected}
+
+
+# ββ Situations reference ββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+@app.get("/api/situations")
+def situations():
+ return SITUATIONS
+
+
+# ββ Batch CSV Upload ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+@app.post("/api/batch/stress")
+async def batch_stress(file: UploadFile = File(...)):
+ """Upload a CSV of sensor windows β get stress predictions for all rows."""
+ content = (await file.read()).decode("utf-8")
+ if not content.strip():
+ raise HTTPException(400, "Empty file")
+ result = process_stress_csv(content)
+ if "error" in result and result["error"]:
+ raise HTTPException(400, result["error"])
+ return result
+
+
+@app.post("/api/batch/earnings")
+async def batch_earnings(file: UploadFile = File(...)):
+ """Upload a CSV of trip/earnings data β get velocity predictions for all rows."""
+ content = (await file.read()).decode("utf-8")
+ if not content.strip():
+ raise HTTPException(400, "Empty file")
+ result = process_earnings_csv(content)
+ if "error" in result and result["error"]:
+ raise HTTPException(400, result["error"])
+ return result
+
+
+@app.get("/api/batch/template/stress", response_class=PlainTextResponse)
+def stress_template():
+ """Download a CSV template for stress batch upload."""
+ return PlainTextResponse(
+ content=stress_csv_template(),
+ media_type="text/csv",
+ headers={"Content-Disposition": "attachment; filename=stress_template.csv"},
+ )
+
+
+@app.get("/api/batch/template/earnings", response_class=PlainTextResponse)
+def earnings_template():
+ """Download a CSV template for earnings batch upload."""
+ return PlainTextResponse(
+ content=earnings_csv_template(),
+ media_type="text/csv",
+ headers={"Content-Disposition": "attachment; filename=earnings_template.csv"},
+ )
+
+
+# ββ Trips CSV Import (creates trips) βββββββββββββββββββββββββββββββββββββββ
+
+@app.post("/api/trips/import-csv")
+async def import_trips(file: UploadFile = File(...)):
+ """Upload a Trips CSV and persist rows as trips (in-memory)."""
+ content = (await file.read()).decode("utf-8")
+ if not content.strip():
+ raise HTTPException(400, "Empty file")
+ result = import_trips_csv(content)
+ if "error" in result and result["error"]:
+ raise HTTPException(400, result["error"])
+ return result
+
+
+# ββ Single-row manual prediction ββββββββββββββββββββββββββββββββββββββββββ
+
+@app.post("/api/predict/stress")
+def predict_stress(payload: dict):
+ """Submit a single row of sensor features β get stress prediction."""
+ try:
+ # Add constant features
+ payload.setdefault("motion_std", 0.3)
+ payload.setdefault("z_dev_max", 0.5)
+ payload.setdefault("spikes_above3", 0)
+ payload.setdefault("spikes_above5", 0)
+ payload.setdefault("audio_class_max", 2.0)
+ payload.setdefault("audio_class_mean", 1.0)
+ payload.setdefault("sustained_max", 10.0)
+ payload.setdefault("sustained_sum", 50.0)
+ payload.setdefault("cadence_var_max", 0.6)
+ payload.setdefault("audio_leads_motion", 0.0)
+ payload.setdefault("audio_onset_sec", 0.0)
+ payload.setdefault("brake_t_sec", 0.0)
+ payload.setdefault("is_low_speed", 0)
+ payload.setdefault("both_elevated", 0)
+ payload.setdefault("audio_only", 0)
+ result = predict_stress_row(payload)
+ return result
+ except Exception as e:
+ raise HTTPException(400, str(e))
+
+
+@app.post("/api/predict/earnings")
+def predict_earnings(payload: dict):
+ """Submit a single row of earnings features β get velocity prediction."""
+ try:
+ result = predict_earnings_row(payload)
+ return result
+ except Exception as e:
+ raise HTTPException(400, str(e))
+
+
+@app.get("/api/features/stress")
+def stress_features():
+ """Return the list of stress model input features with defaults."""
+ features = [
+ {"name": "motion_max", "label": "Motion Max (g)", "default": 1.5, "group": "Motion"},
+ {"name": "motion_mean", "label": "Motion Mean (g)", "default": 0.8, "group": "Motion"},
+ {"name": "motion_p95", "label": "Motion P95 (g)", "default": 1.2, "group": "Motion"},
+ {"name": "brake_intensity", "label": "Brake Intensity", "default": 0.5, "group": "Motion"},
+ {"name": "lateral_max", "label": "Lateral Max (g)", "default": 0.8, "group": "Motion"},
+ {"name": "speed_mean", "label": "Speed Mean (km/h)", "default": 30.0, "group": "Speed"},
+ {"name": "speed_at_brake", "label": "Speed at Brake (km/h)", "default": 25.0, "group": "Speed"},
+ {"name": "speed_drop", "label": "Speed Drop (km/h)", "default": 5.0, "group": "Speed"},
+ {"name": "audio_db_max", "label": "Audio dB Max", "default": 65.0, "group": "Audio"},
+ {"name": "audio_db_mean", "label": "Audio dB Mean", "default": 55.0, "group": "Audio"},
+ {"name": "audio_db_p90", "label": "Audio dB P90", "default": 62.0, "group": "Audio"},
+ {"name": "audio_db_std", "label": "Audio dB Std", "default": 5.0, "group": "Audio"},
+ {"name": "cadence_var_mean", "label": "Cadence Var Mean", "default": 0.3, "group": "Voice"},
+ {"name": "argument_frac", "label": "Argument Fraction", "default": 0.0, "group": "Voice"},
+ {"name": "loud_frac", "label": "Loud Fraction", "default": 0.1, "group": "Voice"},
+ ]
+ return {"features": features, "total": len(features)}
+
+
+@app.get("/api/features/earnings")
+def earnings_features():
+ """Return the list of earnings model input features with defaults."""
+ features = [
+ {"name": "avg_earnings_per_hour", "label": "Avg Earnings/hr (βΉ)", "default": 250.0, "group": "Earnings"},
+ {"name": "experience_months", "label": "Experience (months)", "default": 12, "group": "Driver"},
+ {"name": "rating", "label": "Rating (1-5)", "default": 4.5, "group": "Driver"},
+ {"name": "target_earnings", "label": "Target Earnings (βΉ)", "default": 2000.0, "group": "Earnings"},
+ {"name": "remaining_earnings", "label": "Remaining Earnings (βΉ)", "default": 1200.0, "group": "Earnings"},
+ {"name": "remaining_hours", "label": "Remaining Hours", "default": 5.0, "group": "Time"},
+ {"name": "required_velocity", "label": "Required Velocity (βΉ/hr)", "default": 240.0, "group": "Earnings"},
+ {"name": "trips_completed", "label": "Trips Completed", "default": 5, "group": "Trip"},
+ {"name": "trip_rate", "label": "Trip Rate (trips/hr)", "default": 2.0, "group": "Trip"},
+ {"name": "hour_of_day", "label": "Hour of Day (0-23)", "default": 14, "group": "Time"},
+ {"name": "is_morning_rush", "label": "Morning Rush (0/1)", "default": 0, "group": "Time"},
+ {"name": "is_lunch_rush", "label": "Lunch Rush (0/1)", "default": 0, "group": "Time"},
+ {"name": "is_evening_rush", "label": "Evening Rush (0/1)", "default": 0, "group": "Time"},
+ {"name": "current_velocity", "label": "Current Velocity (βΉ/hr)", "default": 200.0, "group": "Earnings"},
+ ]
+ return {"features": features, "total": len(features)}
+
+
+if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..fcec658
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,9 @@
+fastapi>=0.104
+uvicorn>=0.24
+pydantic>=2.0
+python-multipart>=0.0.6
+numpy>=1.24.0
+pandas>=2.0.0
+scikit-learn>=1.3.0
+joblib>=1.3.0
+python-dateutil>=2.8
\ No newline at end of file
diff --git a/backend/utils/logging.py b/backend/utils/logging.py
new file mode 100644
index 0000000..661fdb5
--- /dev/null
+++ b/backend/utils/logging.py
@@ -0,0 +1,20 @@
+"""
+Very small logging helper for the demo backend.
+In a real deployment this is where structured logging or APM hooks would live.
+"""
+
+from datetime import datetime
+
+
+def _ts() -> str:
+ return datetime.now().isoformat()
+
+
+def log_info(message: str) -> None:
+ print(f"[info] { _ts() } {message}")
+
+
+def log_warn(message: str) -> None:
+ print(f"[warn] { _ts() } {message}")
+
+
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..9a0de62
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,21 @@
+version: "3.9"
+
+services:
+ backend:
+ build:
+ context: .
+ dockerfile: Dockerfile.backend
+ container_name: drivepulse-backend
+ ports:
+ - "8000:8000"
+
+ frontend:
+ build:
+ context: .
+ dockerfile: Dockerfile.frontend
+ container_name: drivepulse-frontend
+ ports:
+ - "5173:80"
+ depends_on:
+ - backend
+
diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md
new file mode 100644
index 0000000..c5631a3
--- /dev/null
+++ b/docs/PROGRESS_LOG.md
@@ -0,0 +1,27 @@
+# Progress Log (Development History)
+
+A chronological log of our problem-solving process and major iterations.
+
+
+---
+
+**Mar 4:** Project kickoff. Team discussion on requirements, user persona, and value proposition. Decided on stress + earnings as dual focus.
+
+**Mar 5:** Finalized tech stack (FastAPI, React, scikit-learn). Planned architecture and repo structure. Set up the project repository and initial folder structure.
+
+**Mar 6:** Backend development: implemented API skeleton (trips, goals, dashboard), created synthetic trip data generator, and batch processor.
+
+**Mar 7:** Backend ML: built and trained initial stress detection (RF, HAL, Platt calibration) and earnings velocity models. Added endpoints for batch inference and CSV import.
+
+**Mar 8:** Frontend development: built dashboard, trip list, trip detail, earnings progress bar. Integrated backend APIs. Added batch upload and explainability features.
+
+**Mar 9:** Documentation: wrote design doc (HTML), architecture diagram (SVG), and progress log. Re-checked all features, tested flows, and made final edits based on feedback. Prepared for deployment.
+
+**Mar 10:** Deployment and submission: added Dockerised deployment (FastAPI + React via Nginx, `docker-compose.yml` at repo root), verified end-to-end flows in containers, and documented judge login plus Docker run instructions in all README/docs before final submission.
+
+---
+
+**Outcome:**
+Successfully built and deployed DrivePulse β a driver safety and earnings intelligence platform that transforms raw trip signals into actionable insights. The system combines real-time stress detection with earnings velocity forecasting while maintaining privacy through on-device processing. All core features (stress detection, earnings forecasting, explainability tools, batch testing, and feedback collection) were implemented as planned. Documentation, architecture diagrams, and progress logs were completed and the project was submitted on time.
+
+---
\ No newline at end of file
diff --git a/docs/architecture_explanation.html b/docs/architecture_explanation.html
new file mode 100644
index 0000000..88a0b2d
--- /dev/null
+++ b/docs/architecture_explanation.html
@@ -0,0 +1,327 @@
+
+
+
+
+
+ DrivePulse β System Architecture
+
+
+
+
+
+
+
π DrivePulse
+
System Architecture
+
UBER SHE++ HACKATHON 2026
+
Merin Rishit Lavisha
+
+
+
+
+
+
+
+
+ 01 Architecture Diagram
+ End-to-end data flow: sensors β on-device processing β server β driver insights β feedback loop.
+
+
+
+
+
+
+
+
+ ON-DEVICE (EDGE)
+
+
+ OFF-DEVICE (SERVER)
+
+
+ FEEDBACK LOOP
+
+
+
+ π± Accelerometer
+ 10 Hz x/y/z motion
+
+
+ π€ Microphone
+ dB envelope only
+
+
+ π GPS + Speed
+ km/h, lat/lng
+
+
+ π° Trip Earnings
+ βΉ per trip, hours
+
+
+
+
+
+
+
+
+ HAL Calibration
+ Device normalisation
+ AGC + road baseline
+
+
+
+
+
+
+ Feature Extraction
+ 30s windows, Z-score norm
+ ~12 stress / 14 earnings feats
+
+
+
+
+
+
+
+
+
+ Local ML Inference
+ RF Classifier (stress) Β· <1ms
+ RF Regressor (earnings)
+
+
+
+ REST
+
+
+
+
+
+
+ π Driver Alerts
+ Real-time, on-device
+
+
+
+ π Driver Dashboard
+ Glanceable UI
+
+
+
+
+
+
+ FastAPI Backend
+ 25 REST endpoints
+ Auth, CORS, validation
+
+
+
+ Data Store
+ In-memory (MVP)
+ Trips, goals, events
+
+
+
+
+ Batch Processor
+ CSV upload β inference
+ Stress + Earnings models
+
+
+
+
+ Model Artifacts
+ rf_model.pkl (Γ2)
+ baselines, contracts
+
+
+
+
+ Aggregation Engine
+ Dashboard summaries
+ Trends, goal tracking
+
+
+
+
+ Driver Web App (React)
+ 8 pages, 16 components
+ Maps, charts, modals
+
+
+
+
+
+
+ ππ Feedback
+ Collected per event
+
+
+
+
+ π Retrain Loop
+ Future: feedback β model
+
+
+
+
+
+
+
+
+
+
+
+ DrivePulse β End-to-End Data Flow
+
+
+
Right-click β "Save image as" or screenshot this diagram to export as PNG.
+
+
+
+
+
+ 02 Architecture Explanation
+ Design decisions and engineering trade-offs.
+
+ Real-Time vs. Post-Trip Processing
+
+ Stress detection β real-time, on-device. RF classifier runs per 30s window in <1ms. Driver gets alerts instantly, no network needed.
+ Earnings prediction β on-device per trip. Velocity forecasting runs locally after each trip completes.
+ Aggregation β server-side. Dashboard summaries, trends, and goal tracking are computed on the server where full trip history is available.
+ Why this split? A driver in danger can't wait for a network round-trip. Safety alerts must work in tunnels and dead zones.
+
+
+ Connectivity & Resilience
+
+ Both ML models run locally β no network required for predictions.
+ Queued sync β trip data and feedback stored locally, synced when connectivity returns.
+ Rule-based fallback β if model files are missing, threshold rules (motion >3.5g + audio >80 dB β CONFLICT) keep the app functional.
+ SPA frontend β once loaded, all navigation works offline; only API data calls need network.
+
+
+ Battery & Resource Management
+
+ Lightweight RF models β no GPU, <1ms inference, ~12 stress features (not hundreds).
+ 10 Hz sampling β sufficient for brakes/road events; 10Γ less data than high-frequency approaches.
+ 30s windows β model runs ~2Γ/min, not continuously.
+ dB envelope only β loudness levels, not raw audio waveforms. No FFT/spectrogram cost.
+ EMA drift correction β simple update every 10 min, not a full recalibration.
+
+
+ Privacy & Data Minimisation
+
+ No raw audio β mic input reduced to dB aggregates on-device. No recordings leave the phone.
+ No raw sensor logs β accelerometer data consumed in 30s windows, converted to statistical features, then discarded.
+ All audio classification on-device β server never receives audio data.
+ Minimal PII β username + city + experience level. No phone number, no payment data.
+ Feedback is opt-in β only event ID + boolean. No free-text harvested.
+
+
+ Key Trade-offs
+
+ Decision Gained Gave Up
+ On-device inference Zero-latency alerts, offline support Can't use large models or cross-driver patterns
+ RandomForest over DL Lightweight, interpretable, CPU-only Lower accuracy on complex temporal patterns
+ dB envelope over raw audio Privacy, low CPU, no storage No speech-to-text or fine-grained analysis
+ In-memory store over DB Zero setup for judges No persistence across restarts
+ Feedback collected, not looped Simpler system Model doesn't self-improve (yet)
+
+
+
+
+
+
+ 03 Deployment & Docker
+ How a judge runs the full system in minutes.
+
+
+ Hackathon requirement: Include a working Dockerfile and docker-compose.yml
+ so a judge can run the entire stack with a single command. DrivePulse ships two services
+ (FastAPI backend + React frontend) wired together behind Nginx.
+
+
+ Containers
+
+ Backend container: Python 3.11 + FastAPI. Runs uvicorn main:app inside the backend/ folder so imports like from utils... and from data... match local dev. Exposes 8000 with all /api/* routes.
+ Frontend container: Builds the React SPA with Node + Vite, then serves the static dist/ bundle via Nginx on port 80. Nginx proxies any request to /api/ to the backend service.
+ Compose orchestration: docker-compose.yml in the repo root builds both images, creates a shared network, and publishes:
+
+ http://localhost:5173 β frontend (Nginx)
+ http://localhost:8000 β backend (FastAPI, e.g. /api/health)
+
+
+
+
+ Judge Runbook
+
+ Prerequisite: Install Docker Desktop and ensure it is running.
+ Clone repo: git clone ... && cd Driver-Pulse
+ One command:
+ docker compose up --build from the repo root (Driver-Pulse/).
+ Open app: visit http://localhost:5173 in the browser
+ (no manual dependency installation required).
+
+
+
+ Judge credentials: we provide a pre-populated demo account.
+ Username: judge@uber.com Β· Password: hackathon2026.
+
+
+
+
+
+
+DrivePulse β Uber She++ Hackathon 2026 Β· Merin Β· Rishit Β· Lavisha
+
+
diff --git a/docs/architecture_image.png b/docs/architecture_image.png
new file mode 100644
index 0000000..c1bf0ab
Binary files /dev/null and b/docs/architecture_image.png differ
diff --git a/docs/design.html b/docs/design.html
new file mode 100644
index 0000000..7338047
--- /dev/null
+++ b/docs/design.html
@@ -0,0 +1,321 @@
+
+
+
+
+
+ DrivePulse β Design Document
+
+
+
+
+
+
+
π DrivePulse
+
Design Document
+
UBER SHE++ HACKATHON 2026
+
Merin Rishit Lavisha
+
+
+
+
+
+
+
+
+ 01 Product Vision
+ Who it's for, what it does, and why it's built this way.
+
+ User Persona
+
+ Full-time ride-hailing driver in an Indian metro city (Bangalore in our demo).
+ Drives 8β10 hours daily. Cares about two things: staying safe on chaotic roads and
+ hitting a daily earnings target . Not tech-savvy β needs information delivered at a glance,
+ not buried in charts.
+
+
+ Core Value Proposition
+
+ DrivePulse transforms raw trip signals β accelerometer, speed, audio dB β into two actionable insights:
+
+
+ Safety alerts β Detects dangerous situations (hard braking + loud cabin, escalating conflicts) in real-time and tells the driver why each event was flagged.
+ Earnings forecasting β Predicts earnings velocity (βΉ/hr), shows whether the driver is on track to hit their daily target, and estimates time-to-goal.
+
+
+ The "Glanceable" Test
+
+ A driver can't read a dense dashboard while navigating traffic. Every screen in DrivePulse is designed to pass the 2-second glance test :
+
+
+ Dashboard β 5 large summary cards at the top. Green/yellow/red colour coding. No mental math needed.
+ Today Timeline β Horizontal hour blocks colour-coded by stress. One glance = "how has my day been?"
+ Earnings Progress β Single progress bar with current βΉ/hr vs required βΉ/hr. Status: π’ ahead / π‘ on_track / π΄ at_risk.
+ Event Cards β Emoji + one-line message + confidence badge. No jargon. "π¨ Hard brake + loud cabin at the same time."
+ Stress Tips β Dismissible amber cards with actionable advice. Not data β actions.
+
+
+
+ Design principle: The driver should never have to interpret raw numbers.
+ Every metric is translated into a status (ahead / on_track / at_risk), a colour (green / yellow / red),
+ or a plain-language message. Detailed signal charts and explainability modals exist for the
+ Trip Detail page β but they're opt-in, not the default view.
+
+
+
+
+
+ 02 Stress Detection Algorithm
+ Lightweight signal processing, sensor fusion, and false-positive reduction.
+
+ Pipeline
+
+
Raw Sensors10 Hz
β
+
HALDevice Norm
β
+
30s Window~12 features
β
+
Z-ScoreBaseline ΞΌ/Ο
β
+
RF Classifyβ 7 classes
β
+
Platt Calβ true prob
+
+
+ Cleaning Noisy Motion Data (HAL)
+ Phone sensors are noisy and device-dependent. Our Hardware Abstraction Layer runs a two-phase calibration:
+
+ Phase 1 β Audio baseline: 30s stationary recording. Uses 10th-percentile dB as ambient floor, computes mic gain correction (target 52 dB). Normalises mic sensitivity across phone models.
+ Phase 2 β Road baseline: First 2 min of driving at >20 km/h. Detects gravity axis, computes accelerometer scale factor, and measures road vibration baseline. Subtracts road noise from real events.
+ Drift correction: EMA update (Ξ±=0.15) every 10 min adjusts audio floor and vibration baseline.
+
+ After HAL, accelerometer output = net motion above road vibration , audio = net cabin noise above ambient . Device-agnostic.
+
+ Interpreting Audio Intensity
+ Audio features are acoustic aggregates only β no raw audio stored (privacy by design). Key features per 30s window:
+
+ audio_db_mean / p90 β overall cabin loudness
+ sustained_max β duration of sustained loud episodes
+ cadence_var β speech-like cadence variation (arguments = irregular cadence; music = steady)
+ audio_leads_motion β did audio spike before a hard brake? (escalation signal)
+
+ The insight: arguments have irregular cadence + sustained loudness , while music has regular cadence + steady levels. These two features alone separate ARGUMENT from MUSIC_OR_CALL well.
+
+ Multi-Sensor Fusion
+ Dangerous situations are rarely single-sensor. The key fusion features:
+
+ both_elevated β motion AND audio above threshold simultaneously β strongest CONFLICT predictor.
+ audio_leads_motion β audio spikes >5s before hard brake β ESCALATING (argument β panic brake).
+ audio_only β high audio, no motion anomaly β ARGUMENT_ONLY or MUSIC_OR_CALL, not a road event.
+
+
+ Reducing False Positives
+
+ Platt calibration: Sigmoid recalibration so 85% confidence β 85% true probability. Alerts only on HIGH (β₯75%).
+ Confidence gating: If top-class confidence <50%, system defaults to NORMAL β no alert.
+ Class weighting: CONFLICT and ESCALATING weighted 3Γ during training (favour recall on dangerous events); Platt step corrects the resulting overconfidence.
+ Rule-based fallback: If model files are missing, simple thresholds (motion >3.5g + audio >80 dB β CONFLICT) ensure the app never crashes.
+
+
+ Output
+ Per 30s window: situation class (7 classes), calibrated confidence (HIGH / MEDIUM / LOW), top feature deviations (for explainability), safety flag, <1ms inference on CPU.
+
+
+
+
+ 03 Earnings Velocity Algorithm
+ Current speed, forecasting, and cold-start handling.
+
+ Pipeline
+
+
Driver Statetrips, hours, βΉ
β
+
Feature Eng14 features
β
+
RF Regress300 trees
β
+
βΉ/hr + Status+ probability
+
+
+ Current Earnings Speed
+ Earnings velocity = cumulative earnings Γ· elapsed hours (βΉ/hr). Required velocity = remaining earnings Γ· remaining hours. The gap between the two gives an instant "am I on track?" signal.
+
+ Feature Engineering (14 features)
+
+ Group Features Why
+ Current current_velocity, elapsed_hours, trips_completed, trip_rateHow is the driver doing right now?
+ Context hour_of_day, is_morning/lunch/evening_rushDemand varies by time of day
+ History velocity_last_1/2/3, rolling_velocity_3/5Recent trend (momentum)
+ Goal goal_pressure (target β current velocity)How urgently driver needs to speed up
+
+
+ Forecasting
+ RF regressor predicts next-period velocity . From that we derive:
+
+ Forecast status: predicted > required β ahead Β· |diff| < βΉ20 β on_track Β· else at_risk
+ Goal probability: clamp(1 β |predicted β required| / required, 0, 1)
+ Time to goal: remaining earnings Γ· predicted velocity
+
+
+ Handling Cold Start
+ First 15 min of a shift β no velocity lags, unstable trip rate, no rolling averages.
+
+ Backfill: Missing lags set to current velocity (conservative β assumes no trend until proven).
+ Rolling windows: Use whatever data points are available, then backfill remaining NaN.
+ Goal pressure as anchor: Even with zero history, the model knows target vs current velocity β a strong directional signal from reading one.
+ Static profile features: avg_earnings_per_hour, experience_months, rating are available from registration and provide baseline expectations.
+
+
+
+ Edge case: A driver with a βΉ5,000 target and 2 hours left sees required = βΉ2,500/hr β clearly unreachable.
+ The model predicts honestly; the dashboard shows π΄ at_risk. We don't hide bad news.
+
+
+
+
+
+ 04 Execution Strategy
+ MVP scope, phased build, and the cut line.
+
+ MVP Scope
+
+ Layer What's included
+ ML β Stress Lightweight RF classifier, ~12 key features, 7 classes, Platt calibration, HAL, rule-based fallback
+ ML β Earnings RF regressor, 14 features, velocity prediction, 5 derived goal metrics
+ Backend FastAPI (25 endpoints), in-memory store, batch processor, CSV import, auth
+ Frontend React SPA (8 pages, 16 components), maps, charts, explainability modals
+ Judge tools Predict page (single-row testing), Batch Upload (CSV inference + charts)
+
+
+ Phased Build
+
+ Data & Models β Feature engineering, train both RF models, validate with cross-validation.
+ Backend API β FastAPI with core endpoints, in-memory data store, batch inference.
+ Frontend Core β Dashboard, Trips, Trip Detail with map + charts, auth flow.
+ Polish & Judge Tools β Predict, Batch Upload, explainability, Trends, Goals.
+ Docs & Deploy β Design doc, architecture diagram, README, deployment.
+
+
+ The Cut Line
+
+
+
What we cut:
+
+ Real-time phone sensor streaming (simulated with synthetic data)
+ Persistent database (in-memory = zero-setup for judges)
+ Push notifications / WebSockets
+ Model retraining from feedback
+ Payment integration / real fare calculation
+
+
+
+
Why:
+
+ Each adds setup complexity without demonstrating core algorithm thinking
+ Architecture supports a DB β we just don't require one to run
+ Feedback collection is built; retraining is a production concern
+ Time focused on model quality + explainability + UX
+
+
+
+
+ Key Trade-offs
+
+ Choice Gained Gave Up
+ RandomForest over deep learning Lightweight, interpretable, CPU-only, <1ms inference Potentially lower accuracy on complex patterns
+ In-memory store over DB Zero setup, instant startup No persistence across restarts
+ Synthetic sensor data Full control over class balance May not generalise to real sensors
+
+
+
+
+
+ 05 Judge Experience & Docker
+ How we designed the repo and Docker setup so judges can run DrivePulse in a few minutes.
+
+ Zero-setup runtime
+ To avoid asking judges to install Python, Node, or ML libraries locally, we ship a fully containerised stack:
+
+ Backend: Dockerfile.backend builds a Python 3.11 image, installs all ML and FastAPI dependencies from the repo's requirements.txt files, and starts uvicorn main:app from backend/.
+ Frontend: Dockerfile.frontend builds the React + Vite SPA, then serves it via Nginx, which also proxies /api/* to the backend service.
+ Compose: docker-compose.yml at the root orchestrates both services and exposes:
+
+ http://localhost:5173 β Nginx + React SPA
+ http://localhost:8000/api/health β backend health check
+
+
+
+
+ Judge run instructions
+ We repeat the exact steps (as required by the hackathon) in the root README.md and docs:
+
+ Install Docker Desktop and ensure it is running.
+ git clone ... && cd Driver-Pulse
+ From the repo root run: docker compose up --build
+ Visit http://localhost:5173 to use the app; optional backend health at http://localhost:8000/api/health.
+
+
+
+ Judge login: we provide a pre-configured demo account so judges land in a realistic dashboard immediately.
+ Username: judge@uber.com Β· Password: hackathon2026.
+
+
+
+ This approach satisfies the hackathon's Docker criterion: a working Dockerfile and docker-compose.yml
+ at the repo root, with a single docker compose up --build command to start the entire application.
+
+
+
+
+
+DrivePulse β Uber She++ Hackathon 2026 Β· Merin Β· Rishit Β· Lavisha
+
+
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..011ae1e
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+ DrivePulse β Driver Wellness & Earnings
+
+
+
+
+
+
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..2ad91dd
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,3105 @@
+{
+ "name": "drivepulse-frontend",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "drivepulse-frontend",
+ "version": "1.0.0",
+ "dependencies": {
+ "date-fns": "^3.2.0",
+ "leaflet": "^1.9.4",
+ "lucide-react": "^0.302.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-leaflet": "^4.2.1",
+ "react-router-dom": "^6.21.0",
+ "recharts": "^2.10.0"
+ },
+ "devDependencies": {
+ "@types/leaflet": "^1.9.8",
+ "@vitejs/plugin-react": "^4.2.0",
+ "autoprefixer": "^10.4.16",
+ "postcss": "^8.4.32",
+ "tailwindcss": "^3.4.0",
+ "vite": "^5.0.0"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
+ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
+ "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "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",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "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",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "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"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "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",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@react-leaflet/core": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
+ "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
+ "license": "Hippocratic-2.1",
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ }
+ },
+ "node_modules/@remix-run/router": {
+ "version": "1.23.2",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
+ "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
+ "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
+ "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
+ "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
+ "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
+ "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
+ "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
+ "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
+ "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
+ "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
+ "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
+ "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
+ "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
+ "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
+ "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
+ "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
+ "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
+ "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
+ "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
+ "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
+ "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
+ "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
+ "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
+ "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/leaflet": {
+ "version": "1.9.21",
+ "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
+ "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.4.27",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
+ "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.1",
+ "caniuse-lite": "^1.0.30001774",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
+ "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001777",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
+ "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/date-fns": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
+ "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.307",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
+ "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+ "license": "MIT"
+ },
+ "node_modules/fast-equals": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
+ "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fsevents": {
+ "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,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/leaflet": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+ "license": "BSD-2-Clause",
+ "peer": true
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash": {
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.302.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.302.0.tgz",
+ "integrity": "sha512-JZX+1fjpqxvQmEgItvPOAwRlqf0Eg9dSZMxljA2/V2M6dluOhQCPBhewIlSJWgkNu0M36kViOgmTAMnDaAMOFw==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.36",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
+ "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "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": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/prop-types/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "license": "MIT"
+ },
+ "node_modules/react-leaflet": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
+ "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
+ "license": "Hippocratic-2.1",
+ "dependencies": {
+ "@react-leaflet/core": "^2.1.0"
+ },
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
+ "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
+ "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.2",
+ "react-router": "6.30.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/react-smooth": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
+ "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-equals": "^5.0.1",
+ "prop-types": "^15.8.1",
+ "react-transition-group": "^4.4.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/recharts": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
+ "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.0.0",
+ "eventemitter3": "^4.0.1",
+ "lodash": "^4.17.21",
+ "react-is": "^18.3.1",
+ "react-smooth": "^4.0.4",
+ "recharts-scale": "^0.4.4",
+ "tiny-invariant": "^1.3.1",
+ "victory-vendor": "^36.6.8"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/recharts-scale": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+ "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+ "license": "MIT",
+ "dependencies": {
+ "decimal.js-light": "^2.4.1"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
+ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.59.0",
+ "@rollup/rollup-android-arm64": "4.59.0",
+ "@rollup/rollup-darwin-arm64": "4.59.0",
+ "@rollup/rollup-darwin-x64": "4.59.0",
+ "@rollup/rollup-freebsd-arm64": "4.59.0",
+ "@rollup/rollup-freebsd-x64": "4.59.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.59.0",
+ "@rollup/rollup-linux-arm64-musl": "4.59.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.59.0",
+ "@rollup/rollup-linux-loong64-musl": "4.59.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.59.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.59.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-musl": "4.59.0",
+ "@rollup/rollup-openbsd-x64": "4.59.0",
+ "@rollup/rollup-openharmony-arm64": "4.59.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.59.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.59.0",
+ "@rollup/rollup-win32-x64-gnu": "4.59.0",
+ "@rollup/rollup-win32-x64-msvc": "4.59.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "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/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "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"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/victory-vendor": {
+ "version": "36.9.2",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
+ "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..0bd8f65
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "drivepulse-frontend",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.21.0",
+ "recharts": "^2.10.0",
+ "react-leaflet": "^4.2.1",
+ "leaflet": "^1.9.4",
+ "lucide-react": "^0.302.0",
+ "date-fns": "^3.2.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^4.2.0",
+ "vite": "^5.0.0",
+ "tailwindcss": "^3.4.0",
+ "postcss": "^8.4.32",
+ "autoprefixer": "^10.4.16",
+ "@types/leaflet": "^1.9.8"
+ }
+}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000..2e7af2b
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
new file mode 100644
index 0000000..d9d41b4
--- /dev/null
+++ b/frontend/src/App.jsx
@@ -0,0 +1,64 @@
+import { Routes, Route, Navigate } from 'react-router-dom'
+import { useState, useEffect } from 'react'
+import Layout from './components/Layout'
+import Dashboard from './pages/Dashboard'
+import Trips from './pages/Trips'
+import TripDetail from './pages/TripDetail'
+import Trends from './pages/Trends'
+import Goals from './pages/Goals'
+import BatchUpload from './pages/BatchUpload'
+import Predict from './pages/Predict'
+import Home from './pages/Home'
+
+export default function App() {
+ const [user, setUser] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ // Check if user is already logged in
+ const storedUser = localStorage.getItem('user')
+ if (storedUser) {
+ try {
+ setUser(JSON.parse(storedUser))
+ } catch (e) {
+ localStorage.removeItem('user')
+ }
+ }
+ setLoading(false)
+ }, [])
+
+ const handleLoginSuccess = (userData) => {
+ setUser(userData)
+ }
+
+ const handleLogout = () => {
+ setUser(null)
+ localStorage.removeItem('user')
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (!user) {
+ return
+ }
+
+ return (
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ )
+}
diff --git a/frontend/src/__tests__/EarningsProgress.test.jsx b/frontend/src/__tests__/EarningsProgress.test.jsx
new file mode 100644
index 0000000..cce50bd
--- /dev/null
+++ b/frontend/src/__tests__/EarningsProgress.test.jsx
@@ -0,0 +1,32 @@
+// NOTE: Illustrative test only β runner not wired.
+// Shows how EarningsProgress could be tested with a real test setup.
+
+import EarningsProgress from '../components/EarningsProgress'
+
+const mockGoals = {
+ daily_target: 1800,
+ current_earnings: 900,
+ current_velocity: 200,
+ required_velocity: 225,
+ goal_probability: 0.75,
+ current_hours: 4.5,
+ target_hours: 10,
+ trips_completed: 6,
+ forecast_status: 'on_track',
+}
+
+// Pseudo-test demonstrating expected derived values.
+function exampleUsage() {
+ const pct = Math.round((mockGoals.current_earnings / mockGoals.daily_target) * 100)
+ const remaining = mockGoals.daily_target - mockGoals.current_earnings
+
+ // In a real test you might assert:
+ // expect(screen.getByText(`βΉ${mockGoals.current_earnings.toLocaleString()}`)).toBeInTheDocument()
+ // expect(screen.getByText(`${pct}% achieved`)).toBeInTheDocument()
+ // expect(screen.getByText(`βΉ${remaining.toLocaleString()} remaining`)).toBeInTheDocument()
+
+ return { pct, remaining }
+}
+
+export { EarningsProgress, mockGoals, exampleUsage }
+
diff --git a/frontend/src/__tests__/TripsAddTrip.test.jsx b/frontend/src/__tests__/TripsAddTrip.test.jsx
new file mode 100644
index 0000000..648a327
--- /dev/null
+++ b/frontend/src/__tests__/TripsAddTrip.test.jsx
@@ -0,0 +1,43 @@
+// NOTE: Illustrative test only β runner not wired.
+// Shows intended behaviour for adding trips vs goals aggregation.
+
+import { isValidTimeRange } from '../utils/sanityChecks'
+
+// Minimal shapes copied from backend logic for explanation purposes only.
+const TODAY = '2026-03-08'
+
+function goalsFromTrips(trips, goals) {
+ const todayTrips = trips.filter(t => t.date === TODAY)
+ const current_earnings = todayTrips.reduce((sum, t) => sum + (t.fare || 0), 0)
+ const current_hours = todayTrips.reduce((sum, t) => sum + (t.duration_min || 0), 0) / 60
+
+ return {
+ ...goals,
+ current_earnings,
+ current_hours,
+ }
+}
+
+// Pseudo-test: adding a trip on a non-today date should NOT affect today's goals.
+function exampleTripBehaviour() {
+ const baseGoals = { daily_target: 1800, current_earnings: 1000, current_hours: 5 }
+
+ const trips = [
+ { id: 't1', date: TODAY, fare: 500, duration_min: 30 },
+ { id: 't2', date: TODAY, fare: 500, duration_min: 30 },
+ ]
+
+ const withTodayGoals = goalsFromTrips(trips, baseGoals)
+
+ const newTripOtherDay = { id: 't3', date: '2026-03-07', fare: 400, duration_min: 40 }
+ const withOtherDayTripGoals = goalsFromTrips([...trips, newTripOtherDay], baseGoals)
+
+ // In a real test you might assert:
+ // expect(withOtherDayTripGoals.current_earnings).toBe(withTodayGoals.current_earnings)
+ // expect(withOtherDayTripGoals.current_hours).toBe(withTodayGoals.current_hours)
+
+ return { withTodayGoals, withOtherDayTripGoals }
+}
+
+export { isValidTimeRange, goalsFromTrips, exampleTripBehaviour }
+
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
new file mode 100644
index 0000000..887e60b
--- /dev/null
+++ b/frontend/src/api/client.js
@@ -0,0 +1,82 @@
+// Default to relative /api for local dev and Docker.
+// In hosted environments (Vercel/Netlify), set VITE_API_BASE to your backend URL.
+const BASE = import.meta.env.VITE_API_BASE || '/api';
+
+// Single place to plug in auth headers, retries, error normalisation, etc.
+async function request(path, options = {}) {
+ const res = await fetch(`${BASE}${path}`, {
+ headers: { 'Content-Type': 'application/json', ...options.headers },
+ ...options,
+ });
+ if (!res.ok) {
+ const message = `API ${res.status}: ${res.statusText}`;
+ const error = new Error(message);
+ error.status = res.status;
+ throw error;
+ }
+ return res.json();
+}
+
+export const api = {
+ // Authentication
+ login: (username, password) =>
+ request('/auth/login', {
+ method: 'POST',
+ body: JSON.stringify({ username, password }),
+ }),
+
+ register: (userData) =>
+ request('/auth/register', {
+ method: 'POST',
+ body: JSON.stringify(userData),
+ }),
+
+ listUsers: () => request('/auth/users'),
+
+ // Dashboard
+ getDashboard: () => request('/dashboard'),
+ getProfile: () => request('/profile'),
+
+ // Trips
+ getTrips: (params = {}) => {
+ const qs = new URLSearchParams();
+ Object.entries(params).forEach(([k, v]) => {
+ if (v !== undefined && v !== null && v !== '') qs.set(k, v);
+ });
+ const q = qs.toString();
+ return request(`/trips${q ? '?' + q : ''}`);
+ },
+ getTrip: (id) => request(`/trips/${id}`),
+ getSampleTrip: () => request('/sample-trip'),
+ createTrip: (payload) =>
+ request('/trips', { method: 'POST', body: JSON.stringify(payload) }),
+ importTripsCsv: async (file) => {
+ const formData = new FormData()
+ formData.append('file', file)
+ const res = await fetch(`${BASE}/trips/import-csv`, { method: 'POST', body: formData })
+ if (!res.ok) {
+ const text = await res.text()
+ throw new Error(text || `Import failed (${res.status})`)
+ }
+ return res.json()
+ },
+
+ // Events
+ getTripEvents: (tripId) => request(`/trips/${tripId}/events`),
+ postFeedback: (eventId, label, comment = null) =>
+ request(`/events/${eventId}/feedback`, {
+ method: 'POST',
+ body: JSON.stringify({ label, comment }),
+ }),
+
+ // Goals
+ getGoals: () => request('/goals'),
+ setGoal: (daily_target) =>
+ request('/goals', { method: 'POST', body: JSON.stringify({ daily_target }) }),
+
+ // Metrics
+ getMetrics: (range = '7d') => request(`/metrics?range=${range}`),
+
+ // Tips
+ getTips: () => request('/tips'),
+};
diff --git a/frontend/src/components/ConfidenceBadge.jsx b/frontend/src/components/ConfidenceBadge.jsx
new file mode 100644
index 0000000..9fe323c
--- /dev/null
+++ b/frontend/src/components/ConfidenceBadge.jsx
@@ -0,0 +1,15 @@
+const levelConfig = {
+ low: { bg: 'bg-green-100', text: 'text-green-800', label: 'Low' },
+ medium: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Med' },
+ high: { bg: 'bg-red-100', text: 'text-red-800', label: 'High' },
+}
+
+export default function ConfidenceBadge({ level, score }) {
+ const cfg = levelConfig[level] || levelConfig.low
+ return (
+
+ {cfg.label}
+ {score !== undefined && {Math.round(score * 100)}% }
+
+ )
+}
diff --git a/frontend/src/components/EarningsProgress.jsx b/frontend/src/components/EarningsProgress.jsx
new file mode 100644
index 0000000..2b05641
--- /dev/null
+++ b/frontend/src/components/EarningsProgress.jsx
@@ -0,0 +1,104 @@
+export default function EarningsProgress({ goals }) {
+ if (!goals) return null
+
+ const currentEarnings = Number(goals.current_earnings || 0)
+ const dailyTarget = Number(goals.daily_target || 0)
+ const safeTarget = dailyTarget > 0 ? dailyTarget : 0
+ const pct = safeTarget > 0
+ ? Math.min(100, Math.round((currentEarnings / safeTarget) * 100))
+ : 0
+
+ const statusColor = {
+ ahead: 'text-uber-green',
+ on_track: 'text-uber-blue',
+ at_risk: 'text-uber-red',
+ }[goals.forecast_status] || 'text-uber-gray-500'
+
+ const prettyStatus =
+ typeof goals.forecast_status === 'string'
+ ? goals.forecast_status.replace('_', ' ')
+ : 'on track'
+
+ return (
+
+
+
Daily Earnings Target
+
+ {goals.forecast_status?.replace('_', ' ')}
+
+
+
+
+ βΉ{currentEarnings.toLocaleString()}
+ / βΉ{safeTarget.toLocaleString()}
+
+
+ {/* Progress bar */}
+
+
+ {pct}% achieved
+ βΉ{Math.max(0, safeTarget - currentEarnings).toLocaleString()} remaining
+
+
+ {/* Extra stats */}
+
+
+
βΉ{goals.current_velocity}
+
Current βΉ/hr
+
+
+
βΉ{goals.required_velocity}
+
Required βΉ/hr
+
+
+
+ {Number.isFinite(goals.goal_probability)
+ ? Math.round(goals.goal_probability * 100)
+ : 0}
+ %
+
+
Probability
+
+
+
+ {/* Details grid */}
+
+
+
Remaining
+
βΉ{Math.max(0, safeTarget - currentEarnings).toLocaleString()}
+
to reach target
+
+
+
Time Worked
+
{goals.current_hours}h
+
of {goals.target_hours}h target
+
+
+
Trips Today
+
{goals.trips_completed}
+
completed
+
+
+
Status
+
+ {prettyStatus.charAt(0).toUpperCase() + prettyStatus.slice(1)}
+
+
today's pace
+
+
+
+ )
+}
diff --git a/frontend/src/components/EventCard.jsx b/frontend/src/components/EventCard.jsx
new file mode 100644
index 0000000..be8cab2
--- /dev/null
+++ b/frontend/src/components/EventCard.jsx
@@ -0,0 +1,70 @@
+import { useState } from 'react'
+import ConfidenceBadge from './ConfidenceBadge'
+import FeedbackButtons from './FeedbackButtons'
+import ExplainModal from './ExplainModal'
+import { Info, MapPin } from 'lucide-react'
+
+export default function EventCard({ event, onJumpTo, onFeedback }) {
+ const [showExplain, setShowExplain] = useState(false)
+
+ const severityColor = {
+ low: 'border-l-uber-green',
+ medium: 'border-l-uber-yellow',
+ high: 'border-l-uber-red',
+ }[event.severity] || 'border-l-uber-gray-300'
+
+ const time = event.timestamp
+ ? new Date(event.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
+ : `+${Math.round(event.offset_sec / 60)}m`
+
+ return (
+ <>
+
+
+
+
+ {event.emoji}
+ {event.label.replace(/_/g, ' ')}
+
+
+
{time}
+ {event.explain?.summary && (
+
{event.explain.summary}
+ )}
+
+
+
+ setShowExplain(true)}
+ className="p-1.5 rounded-lg hover:bg-uber-gray-100 text-uber-gray-500 transition-colors"
+ title="Why this happened"
+ >
+
+
+ {onJumpTo && (
+ onJumpTo(event.offset_sec)}
+ className="p-1.5 rounded-lg hover:bg-uber-gray-100 text-uber-blue transition-colors"
+ title="Jump to"
+ >
+
+
+ )}
+
+
+
+
+
+
+
+
+ {showExplain && (
+ setShowExplain(false)} />
+ )}
+ >
+ )
+}
diff --git a/frontend/src/components/ExplainModal.jsx b/frontend/src/components/ExplainModal.jsx
new file mode 100644
index 0000000..2670510
--- /dev/null
+++ b/frontend/src/components/ExplainModal.jsx
@@ -0,0 +1,73 @@
+import { X } from 'lucide-react'
+
+export default function ExplainModal({ event, onClose }) {
+ const { explain } = event
+ if (!explain) return null
+
+ return (
+
+
e.stopPropagation()}
+ >
+ {/* Header */}
+
+
+
Why This Happened
+
+ {event.emoji} {event.label.replace(/_/g, ' ')}
+
+
+
+
+
+
+
+ {/* Top 3 feature contributions */}
+
+
Top Feature Contributions
+
+ {explain.top_features?.map((f, i) => {
+ const pct = Math.round(f.contribution * 100)
+ const barColor = i === 0 ? 'bg-uber-red' : i === 1 ? 'bg-uber-orange' : 'bg-uber-yellow'
+ return (
+
+
+
+ {f.feature.replace(/_/g, ' ')} {f.direction}
+
+ {pct}%
+
+
+
+ )
+ })}
+
+
+
+ {/* Model inputs */}
+
+
Model Inputs (snapshot)
+
+ {explain.model_inputs &&
+ Object.entries(explain.model_inputs).map(([k, v]) => (
+
+ {k.replace(/_/g, ' ')}
+ {v}
+
+ ))}
+
+
+
+ {/* Summary */}
+ {explain.summary && (
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/FeedbackButtons.jsx b/frontend/src/components/FeedbackButtons.jsx
new file mode 100644
index 0000000..d705f50
--- /dev/null
+++ b/frontend/src/components/FeedbackButtons.jsx
@@ -0,0 +1,47 @@
+import { useState } from 'react'
+import { ThumbsUp, ThumbsDown, MinusCircle } from 'lucide-react'
+import { api } from '../api/client'
+
+const options = [
+ { key: 'correct', label: 'Correct', icon: ThumbsUp, color: 'text-uber-green hover:bg-green-50' },
+ { key: 'incorrect', label: 'Incorrect', icon: ThumbsDown, color: 'text-uber-red hover:bg-red-50' },
+ { key: 'not_relevant', label: 'Not Relevant', icon: MinusCircle, color: 'text-uber-gray-500 hover:bg-uber-gray-100' },
+]
+
+export default function FeedbackButtons({ eventId, current, onFeedback }) {
+ const [selected, setSelected] = useState(current || null)
+ const [sending, setSending] = useState(false)
+
+ const handleClick = async (label) => {
+ if (sending) return
+ setSending(true)
+ try {
+ await api.postFeedback(eventId, label)
+ setSelected(label)
+ if (onFeedback) onFeedback(eventId, label)
+ } catch {
+ // silent
+ } finally {
+ setSending(false)
+ }
+ }
+
+ return (
+
+ Feedback:
+ {options.map(({ key, label, icon: Icon, color }) => (
+ handleClick(key)}
+ disabled={sending}
+ className={`flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium transition-colors
+ ${selected === key ? 'ring-2 ring-uber-blue bg-blue-50 text-uber-blue' : color}`}
+ title={label}
+ >
+
+ {label}
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/components/FilterChips.jsx b/frontend/src/components/FilterChips.jsx
new file mode 100644
index 0000000..b079c0d
--- /dev/null
+++ b/frontend/src/components/FilterChips.jsx
@@ -0,0 +1,27 @@
+const presets = [
+ { key: 'high_stress', label: 'High Stress', emoji: 'π΄' },
+ { key: 'high_earnings', label: 'High Earnings', emoji: 'π°' },
+ { key: 'night', label: 'Night Trips', emoji: 'π' },
+ { key: 'short', label: 'Short Trips', emoji: 'β‘' },
+]
+
+export default function FilterChips({ active, onSelect }) {
+ return (
+
+ {presets.map(p => (
+ onSelect(active === p.key ? null : p.key)}
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors border
+ ${active === p.key
+ ? 'bg-uber-black text-white border-uber-black'
+ : 'bg-white text-uber-gray-700 border-uber-gray-200 hover:border-uber-gray-400'
+ }`}
+ >
+ {p.emoji}
+ {p.label}
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx
new file mode 100644
index 0000000..db796d4
--- /dev/null
+++ b/frontend/src/components/Layout.jsx
@@ -0,0 +1,13 @@
+import { Outlet } from 'react-router-dom'
+import Sidebar from './Sidebar'
+
+export default function Layout({ user, onLogout }) {
+ return (
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/SampleTripCard.jsx b/frontend/src/components/SampleTripCard.jsx
new file mode 100644
index 0000000..ea9fd45
--- /dev/null
+++ b/frontend/src/components/SampleTripCard.jsx
@@ -0,0 +1,40 @@
+import { useNavigate } from 'react-router-dom'
+import { Play } from 'lucide-react'
+import { api } from '../api/client'
+import { useState } from 'react'
+
+export default function SampleTripCard() {
+ const navigate = useNavigate()
+ const [loading, setLoading] = useState(false)
+
+ const handlePlay = async () => {
+ setLoading(true)
+ try {
+ const trip = await api.getSampleTrip()
+ navigate(`/trips/${trip.id}`)
+ } catch {
+ alert('Failed to load sample trip')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
Quick Start
+
Explore a Sample Trip
+
+ See stress detection, earnings tracking, and event explainability in action.
+
+
+
+ {loading ? 'Loadingβ¦' : 'Play Sample Trip'}
+
+
+ )
+}
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
new file mode 100644
index 0000000..a1ee69b
--- /dev/null
+++ b/frontend/src/components/Sidebar.jsx
@@ -0,0 +1,89 @@
+import { NavLink } from 'react-router-dom'
+import { LayoutDashboard, MapPin, TrendingUp, Target, Activity, Upload, PenLine, LogOut, User, Star, Truck } from 'lucide-react'
+
+const links = [
+ { to: '/', label: 'Dashboard', icon: LayoutDashboard },
+ { to: '/trips', label: 'Trips', icon: MapPin },
+ { to: '/trends', label: 'Trends', icon: TrendingUp },
+ { to: '/goals', label: 'Goals', icon: Target },
+ { to: '/predict', label: 'Predict', icon: PenLine },
+ { to: '/batch', label: 'Batch Upload', icon: Upload },
+]
+
+export default function Sidebar({ user, onLogout }) {
+ return (
+
+ )
+}
diff --git a/frontend/src/components/SignalCharts.jsx b/frontend/src/components/SignalCharts.jsx
new file mode 100644
index 0000000..f4ada29
--- /dev/null
+++ b/frontend/src/components/SignalCharts.jsx
@@ -0,0 +1,61 @@
+import {
+ LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
+} from 'recharts'
+
+export default function SignalCharts({ signals, cursorTime }) {
+ if (!signals || !signals.timestamps) return null
+
+ const data = signals.timestamps.map((t, i) => ({
+ time: t,
+ timeLabel: `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`,
+ speed: signals.speed[i],
+ accel: signals.accel_magnitude[i],
+ audio: signals.audio_db[i],
+ }))
+
+ const charts = [
+ { key: 'speed', label: 'Speed (km/h)', color: '#276EF1', domain: [0, 80] },
+ { key: 'accel', label: 'Accel Magnitude (g)', color: '#E11900', domain: [0, 8] },
+ { key: 'audio', label: 'Audio (dB)', color: '#FF6937', domain: [30, 100] },
+ ]
+
+ return (
+
+ {charts.map(({ key, label, color, domain }) => (
+
+
{label}
+
+
+
+
+
+ `Time: ${v}`}
+ />
+
+ {cursorTime !== undefined && (
+ null}
+ stroke="transparent"
+ />
+ )}
+
+
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/components/StressTips.jsx b/frontend/src/components/StressTips.jsx
new file mode 100644
index 0000000..1d6c573
--- /dev/null
+++ b/frontend/src/components/StressTips.jsx
@@ -0,0 +1,39 @@
+import { useState, useEffect } from 'react'
+import { api } from '../api/client'
+import { Lightbulb, X } from 'lucide-react'
+
+export default function StressTips() {
+ const [tips, setTips] = useState([])
+ const [dismissed, setDismissed] = useState(new Set())
+
+ useEffect(() => {
+ api.getTips().then(res => setTips(res.tips)).catch(() => {})
+ }, [])
+
+ const visible = tips.filter(t => !dismissed.has(t.id))
+ if (visible.length === 0) return null
+
+ return (
+
+
+
+ Stress-Reduction Tips
+
+ {visible.map(tip => (
+
+
setDismissed(prev => new Set([...prev, tip.id]))}
+ className="absolute top-2 right-2 p-1 rounded hover:bg-amber-100"
+ >
+
+
+
{tip.title}
+
{tip.text}
+
+ {tip.cta}
+
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/components/SummaryCard.jsx b/frontend/src/components/SummaryCard.jsx
new file mode 100644
index 0000000..a06fae3
--- /dev/null
+++ b/frontend/src/components/SummaryCard.jsx
@@ -0,0 +1,14 @@
+export default function SummaryCard({ icon: Icon, label, value, sub, color = 'text-uber-black' }) {
+ return (
+
+
+
+
+
+
{label}
+
{value}
+ {sub &&
{sub}
}
+
+
+ )
+}
diff --git a/frontend/src/components/TimelineSlider.jsx b/frontend/src/components/TimelineSlider.jsx
new file mode 100644
index 0000000..4e0c26c
--- /dev/null
+++ b/frontend/src/components/TimelineSlider.jsx
@@ -0,0 +1,65 @@
+import { Play, Pause, SkipBack, SkipForward } from 'lucide-react'
+import { useState, useEffect, useRef } from 'react'
+
+export default function TimelineSlider({ maxSec, currentSec, onChange, isPlaying, onPlayPause }) {
+ const pct = maxSec > 0 ? (currentSec / maxSec) * 100 : 0
+
+ const formatTime = (s) => {
+ const m = Math.floor(s / 60)
+ const sec = Math.floor(s % 60)
+ return `${m}:${String(sec).padStart(2, '0')}`
+ }
+
+ return (
+
+
+
onChange(Math.max(0, currentSec - 30))}
+ className="p-1.5 rounded-lg hover:bg-uber-gray-100 transition"
+ >
+
+
+
+
+ {isPlaying ? : }
+
+
+
onChange(Math.min(maxSec, currentSec + 30))}
+ className="p-1.5 rounded-lg hover:bg-uber-gray-100 transition"
+ >
+
+
+
+
{formatTime(currentSec)}
+
+ {/* Slider */}
+
+
+
onChange(Number(e.target.value))}
+ className="absolute inset-x-0 w-full h-6 opacity-0 cursor-pointer"
+ />
+
+
+
+
{formatTime(maxSec)}
+
+
+ )
+}
diff --git a/frontend/src/components/TodayTimeline.jsx b/frontend/src/components/TodayTimeline.jsx
new file mode 100644
index 0000000..9e6d486
--- /dev/null
+++ b/frontend/src/components/TodayTimeline.jsx
@@ -0,0 +1,60 @@
+import { useNavigate } from 'react-router-dom'
+
+const stressColors = {
+ low: 'bg-uber-green/20 border-uber-green',
+ medium: 'bg-uber-yellow/20 border-uber-yellow',
+ high: 'bg-uber-red/20 border-uber-red',
+}
+
+export default function TodayTimeline({ trips }) {
+ const navigate = useNavigate()
+ if (!trips || trips.length === 0) return null
+
+ const minH = Math.min(...trips.map(t => new Date(t.start_time).getHours()))
+ const maxH = Math.max(...trips.map(t => new Date(t.end_time).getHours())) + 1
+ const span = Math.max(maxH - minH, 1)
+
+ return (
+
+
Today Timeline
+
+ {/* Hour labels */}
+
+
+ {Array.from({ length: span + 1 }, (_, i) => {
+ const h = minH + i
+ return {h % 12 || 12}{h < 12 ? 'a' : 'p'}
+ })}
+
+
+ {/* Track */}
+
+ {trips.map((t) => {
+ const startH = new Date(t.start_time).getHours() + new Date(t.start_time).getMinutes() / 60
+ const endH = new Date(t.end_time).getHours() + new Date(t.end_time).getMinutes() / 60
+ const left = ((startH - minH) / span) * 100
+ const width = Math.max(((endH - startH) / span) * 100, 2)
+
+ return (
+ navigate(`/trips/${t.id}`)}
+ title={`${t.id} β βΉ${t.fare} β ${t.stress_level} stress`}
+ className={`absolute top-1 h-8 rounded-md border-2 cursor-pointer
+ transition-transform hover:scale-105 hover:z-10
+ ${stressColors[t.stress_level] || 'bg-uber-gray-200 border-uber-gray-300'}`}
+ style={{ left: `${left}%`, width: `${width}%` }}
+ />
+ )
+ })}
+
+
+
+
+ Low
+ Medium
+ High
+
+
+ )
+}
diff --git a/frontend/src/components/TripListItem.jsx b/frontend/src/components/TripListItem.jsx
new file mode 100644
index 0000000..ddc956e
--- /dev/null
+++ b/frontend/src/components/TripListItem.jsx
@@ -0,0 +1,77 @@
+import { useNavigate } from 'react-router-dom'
+import { Clock, DollarSign, AlertTriangle, ChevronRight } from 'lucide-react'
+import ConfidenceBadge from './ConfidenceBadge'
+
+const stressDot = {
+ low: 'bg-uber-green',
+ medium: 'bg-uber-yellow',
+ high: 'bg-uber-red',
+}
+
+export default function TripListItem({ trip }) {
+ const navigate = useNavigate()
+ const start = new Date(trip.start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+ const end = new Date(trip.end_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+
+ return (
+ navigate(`/trips/${trip.id}`)}
+ className="w-full bg-white rounded-xl p-4 shadow-sm border border-uber-gray-100
+ hover:border-uber-gray-300 transition-colors text-left flex items-center gap-4"
+ >
+ {/* Stress dot */}
+
+
+ {/* Info */}
+
+
+ {start} β {end}
+ {trip.duration_min} min
+ {trip.surge_multiplier > 1 && (
+
+ {trip.surge_multiplier}Γ
+
+ )}
+
+
+
βΉ{trip.fare}
+
{trip.distance_km} km
+
{trip.events_count} events
+
+ {/* Event summary pills */}
+ {trip.events_summary && trip.events_summary.length > 0 && (
+
+ {trip.events_summary.slice(0, 3).map((e, i) => (
+
+ {e.label.replace(/_/g, ' ')}
+
+ ))}
+ {trip.events_summary.length > 3 && (
+ +{trip.events_summary.length - 3} more
+ )}
+
+ )}
+
+
+ {/* Stress score */}
+
+
6 ? 'text-uber-red' :
+ trip.stress_score > 3 ? 'text-uber-yellow' : 'text-uber-green'
+ }`}>
+ {trip.stress_score}
+
+
stress
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/TripMap.jsx b/frontend/src/components/TripMap.jsx
new file mode 100644
index 0000000..ce34f1a
--- /dev/null
+++ b/frontend/src/components/TripMap.jsx
@@ -0,0 +1,106 @@
+import { useEffect, useRef } from 'react'
+import L from 'leaflet'
+import 'leaflet/dist/leaflet.css'
+
+// Fix Leaflet default icons
+delete L.Icon.Default.prototype._getIconUrl
+L.Icon.Default.mergeOptions({
+ iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
+ iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
+ shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
+})
+
+const severityColor = { low: '#06C167', medium: '#FFC043', high: '#E11900' }
+
+const eventIcon = (severity) =>
+ L.divIcon({
+ html: `
`,
+ className: '',
+ iconSize: [14, 14],
+ iconAnchor: [7, 7],
+ })
+
+const cursorIcon = L.divIcon({
+ html: `
`,
+ className: '',
+ iconSize: [18, 18],
+ iconAnchor: [9, 9],
+})
+
+export default function TripMap({ route, events, cursorIndex }) {
+ const mapRef = useRef(null)
+ const mapInstance = useRef(null)
+ const cursorMarker = useRef(null)
+
+ useEffect(() => {
+ if (!mapRef.current || !route || route.length === 0) return
+
+ // Init map
+ if (!mapInstance.current) {
+ mapInstance.current = L.map(mapRef.current, {
+ scrollWheelZoom: true,
+ zoomControl: true,
+ })
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ attribution: '© OSM',
+ }).addTo(mapInstance.current)
+ }
+
+ const map = mapInstance.current
+
+ // Clear layers except tiles
+ map.eachLayer(layer => {
+ if (!(layer instanceof L.TileLayer)) map.removeLayer(layer)
+ })
+
+ // Polyline
+ const polyline = L.polyline(route, {
+ color: '#276EF1',
+ weight: 4,
+ opacity: 0.8,
+ }).addTo(map)
+
+ // Start / End
+ L.marker(route[0], {
+ icon: L.divIcon({
+ html: `
`,
+ className: '', iconSize: [12, 12], iconAnchor: [6, 6],
+ }),
+ }).addTo(map).bindPopup('Start')
+
+ L.marker(route[route.length - 1], {
+ icon: L.divIcon({
+ html: `
`,
+ className: '', iconSize: [12, 12], iconAnchor: [6, 6],
+ }),
+ }).addTo(map).bindPopup('End')
+
+ // Event markers
+ events?.forEach(ev => {
+ if (ev.location) {
+ L.marker(ev.location, { icon: eventIcon(ev.severity) })
+ .addTo(map)
+ .bindPopup(`${ev.emoji} ${ev.label.replace(/_/g, ' ')} Confidence: ${Math.round(ev.confidence * 100)}%`)
+ }
+ })
+
+ map.fitBounds(polyline.getBounds(), { padding: [30, 30] })
+
+ // Cursor marker
+ cursorMarker.current = L.marker(route[0], { icon: cursorIcon }).addTo(map)
+
+ return () => {
+ // don't destroy map, just clean up
+ }
+ }, [route, events])
+
+ // Update cursor position
+ useEffect(() => {
+ if (cursorMarker.current && route && cursorIndex !== undefined) {
+ const idx = Math.min(Math.max(0, cursorIndex), route.length - 1)
+ cursorMarker.current.setLatLng(route[idx])
+ }
+ }, [cursorIndex, route])
+
+ return
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..9884599
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,22 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+}
+
+/* Custom scrollbar */
+::-webkit-scrollbar { width: 6px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: #CBCBCB; border-radius: 3px; }
+::-webkit-scrollbar-thumb:hover { background: #AFAFAF; }
+
+/* Leaflet overrides */
+.leaflet-container { border-radius: 12px; }
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
new file mode 100644
index 0000000..b15cdd6
--- /dev/null
+++ b/frontend/src/main.jsx
@@ -0,0 +1,13 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
+import App from './App'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+
+ ,
+)
diff --git a/frontend/src/pages/BatchUpload.jsx b/frontend/src/pages/BatchUpload.jsx
new file mode 100644
index 0000000..d53b067
--- /dev/null
+++ b/frontend/src/pages/BatchUpload.jsx
@@ -0,0 +1,577 @@
+import { useState, useRef } from 'react'
+import { Upload, Download, FileSpreadsheet, Activity, DollarSign, AlertTriangle, ChevronDown, ChevronUp, Info, BarChart3, CheckCircle, XCircle, Bell } from 'lucide-react'
+import {
+ BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
+ PieChart, Pie, Cell, Legend,
+} from 'recharts'
+
+const SEVERITY_COLORS = { low: '#06C167', medium: '#FFC043', high: '#E11900' }
+const FORECAST_COLORS = { ahead: '#06C167', on_track: '#276EF1', at_risk: '#E11900' }
+const SITUATION_COLORS = {
+ NORMAL: '#06C167', TRAFFIC_STOP: '#276EF1', SPEED_BREAKER: '#FFC043',
+ CONFLICT: '#E11900', ESCALATING: '#A3000B', ARGUMENT_ONLY: '#FF6937', MUSIC_OR_CALL: '#7356BF',
+}
+
+export default function BatchUpload() {
+ const [mode, setMode] = useState('stress') // 'stress' | 'earnings'
+ const [file, setFile] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [result, setResult] = useState(null)
+ const [error, setError] = useState(null)
+ const [expandedRow, setExpandedRow] = useState(null)
+ const inputRef = useRef(null)
+
+ const handleUpload = async () => {
+ if (!file) return
+ setLoading(true)
+ setError(null)
+ setResult(null)
+
+ const formData = new FormData()
+ formData.append('file', file)
+
+ try {
+ const endpoint = mode === 'stress' ? '/api/batch/stress' : '/api/batch/earnings'
+ const res = await fetch(endpoint, { method: 'POST', body: formData })
+ if (!res.ok) {
+ const text = await res.text()
+ throw new Error(text || `Upload failed (${res.status})`)
+ }
+ const data = await res.json()
+ setResult(data)
+ } catch (err) {
+ setError(err.message || 'Upload failed')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const downloadTemplate = (type) => {
+ const a = document.createElement('a')
+ a.href = `/api/batch/template/${type}`
+ a.download = `${type}_template.csv`
+ a.click()
+ }
+
+ const downloadResults = () => {
+ if (!result) return
+ const json = JSON.stringify(result, null, 2)
+ const blob = new Blob([json], { type: 'application/json' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `${mode}_batch_results.json`
+ a.click()
+ URL.revokeObjectURL(url)
+ }
+
+ const downloadResultsCSV = () => {
+ if (!result?.results?.length) return
+ const rows = result.results
+ const keys = Object.keys(rows[0]).filter(k => typeof rows[0][k] !== 'object')
+ const header = keys.join(',')
+ const lines = rows.map(r => keys.map(k => {
+ const v = r[k]
+ return v === null || v === undefined ? '' : v
+ }).join(','))
+ const csv = header + '\n' + lines.join('\n')
+ const blob = new Blob([csv], { type: 'text/csv' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `${mode}_batch_results.csv`
+ a.click()
+ URL.revokeObjectURL(url)
+ }
+
+ return (
+
+ {/* Header */}
+
+
Batch CSV Upload
+
+ Upload a CSV file with multiple trip windows to get stress & earnings predictions in bulk β no manual entry needed.
+
+
+
+ {/* Mode toggle */}
+
+ {[
+ { key: 'stress', label: 'Stress Detection', icon: Activity },
+ { key: 'earnings', label: 'Earnings Forecast', icon: DollarSign },
+ ].map(({ key, label, icon: Icon }) => (
+ { setMode(key); setResult(null); setError(null); setFile(null) }}
+ className={`flex items-center gap-2 px-5 py-2 rounded-md text-sm font-medium transition-colors ${
+ mode === key ? 'bg-white text-uber-black shadow-sm' : 'text-uber-gray-500 hover:text-uber-gray-700'
+ }`}
+ >
+
+ {label}
+
+ ))}
+
+
+ {/* Template + Upload card */}
+
+ {/* Template download */}
+
+
+
+
CSV Format
+
+
+ {mode === 'stress'
+ ? 'Each row = one 30-second sensor window. Requires 15 feature columns (motion, audio, speed aggregates). Add optional trip_id and timestamp columns for identification.'
+ : 'Each row = one earnings velocity log entry. Requires driver_id, timestamp, cumulative_earnings, elapsed_hours, current_velocity, target_velocity, velocity_delta, trips_completed, target_earnings.'
+ }
+
+
+
Required columns
+
+ {(mode === 'stress'
+ ? ['motion_max', 'motion_mean', 'motion_p95', 'brake_intensity', 'audio_db_max', 'audio_db_p90', 'speed_mean', '...+8 more']
+ : ['driver_id', 'timestamp', 'cumulative_earnings', 'elapsed_hours', 'current_velocity', 'target_velocity', 'trips_completed', 'target_earnings']
+ ).map(col => (
+
+ {col}
+
+ ))}
+
+
+
downloadTemplate(mode)}
+ className="flex items-center gap-2 px-4 py-2 bg-uber-gray-100 hover:bg-uber-gray-200 rounded-lg text-sm font-medium transition-colors"
+ >
+
+ Download Template CSV
+
+
+
+ {/* File upload */}
+
+
Upload CSV
+
+ {/* Drop zone */}
+
inputRef.current?.click()}
+ onDragOver={(e) => e.preventDefault()}
+ onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) setFile(f) }}
+ className="border-2 border-dashed border-uber-gray-200 rounded-xl p-8 text-center cursor-pointer
+ hover:border-uber-blue hover:bg-blue-50/30 transition-colors"
+ >
+
+
+ {file ? (
+ {file.name}
+ ) : (
+ <>Drop CSV here or click to browse >
+ )}
+
+
Supports .csv files
+
{ if (e.target.files[0]) setFile(e.target.files[0]) }}
+ />
+
+
+ {file && (
+
+
+
+ {file.name}
+ ({(file.size / 1024).toFixed(1)} KB)
+
+
{ setFile(null); setResult(null); setError(null) }}
+ className="text-xs text-uber-gray-400 hover:text-uber-red"
+ >
+ Remove
+
+
+ )}
+
+
+ {loading ? (
+ <>
+
+ Processingβ¦
+ >
+ ) : (
+ <>
+
+ Run {mode === 'stress' ? 'Stress Detection' : 'Earnings Prediction'}
+ >
+ )}
+
+
+
+
+ {/* Error */}
+ {error && (
+
+ Error: {error}
+
+ )}
+
+ {/* Results */}
+ {result && mode === 'stress' &&
}
+ {result && mode === 'earnings' &&
}
+
+ {/* Export buttons */}
+ {result && (
+
+
+ Export JSON
+
+
+ Export CSV
+
+
+ )}
+
+ )
+}
+
+
+/* ββ Stress Results βββββββββββββββββββββββββββββββββββββββ */
+
+function StressResults({ result, expandedRow, setExpandedRow }) {
+ const { summary, results } = result
+
+ const pieData = Object.entries(summary.situation_counts || {}).map(([name, count]) => ({
+ name, value: count, fill: SITUATION_COLORS[name] || '#AFAFAF',
+ }))
+ const severityData = Object.entries(summary.severity_counts || {}).map(([name, count]) => ({
+ name: name.charAt(0).toUpperCase() + name.slice(1), value: count, fill: SEVERITY_COLORS[name],
+ }))
+
+ return (
+ <>
+ {/* Summary cards */}
+
+
+
+
+
+ 3 ? 'text-uber-red' : 'text-uber-green'} />
+
+
+ {/* Charts */}
+
+
+
Situation Distribution
+
+
+ `${name.replace(/_/g, ' ')} (${value})`}>
+ {pieData.map((d, i) => | )}
+
+
+
+
+
+
+
Severity Breakdown
+
+
+
+
+
+
+
+ {severityData.map((d, i) => | )}
+
+
+
+
+
+
+ {/* Results table */}
+
+
+
Per-Window Results ({results.length})
+
+
+
+
+
+ #
+ Trip
+ Situation
+ Severity
+ Confidence
+ Notify
+ Details
+
+
+
+ {results.map((r, i) => (
+ setExpandedRow(expandedRow === i ? null : i)} />
+ ))}
+
+
+
+
+ >
+ )
+}
+
+function StressRow({ r, i, expanded, onToggle }) {
+ return (
+ <>
+
+ {r.row_index + 1}
+ {r.trip_id || r.timestamp || '-'}
+
+
+ {r.emoji}
+ {r.situation_name?.replace(/_/g, ' ')}
+
+
+
+
+ {r.severity}
+
+
+
+
+
+
= 0.75 ? 'bg-uber-green' : r.confidence >= 0.5 ? 'bg-uber-yellow' : 'bg-uber-red'}`}
+ style={{ width: `${Math.round(r.confidence * 100)}%` }}
+ />
+
+
{Math.round(r.confidence * 100)}%
+
+
+
+ {r.should_notify ? : β }
+
+
+
+ {expanded ? : }
+
+
+
+ {expanded && (
+
+
+
+ {/* Top features */}
+ {r.top_features?.length > 0 && (
+
+
Top Feature Deviations
+
+ {r.top_features.map((f, j) => (
+
+
{f.feature}
+
+
z={f.z_score}
+
val={f.value}
+
+ ))}
+
+
+ )}
+ {/* All probabilities */}
+ {r.all_probabilities && Object.keys(r.all_probabilities).length > 0 && (
+
+
Class Probabilities
+
+ {Object.entries(r.all_probabilities).sort(([,a], [,b]) => b - a).map(([cls, prob]) => (
+
+
{cls.replace(/_/g, ' ')}
+
+
{(prob * 100).toFixed(1)}%
+
+ ))}
+
+
+ )}
+
+
+
+ )}
+ >
+ )
+}
+
+
+/* ββ Earnings Results βββββββββββββββββββββββββββββββββββββ */
+
+function EarningsResults({ result, expandedRow, setExpandedRow }) {
+ const { summary, results } = result
+
+ const forecastData = Object.entries(summary.forecast_counts || {}).map(([name, count]) => ({
+ name: name.replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase()),
+ value: count,
+ fill: FORECAST_COLORS[name] || '#AFAFAF',
+ }))
+
+ const velocityTrend = results.map((r, i) => ({
+ idx: i + 1,
+ predicted: r.predicted_velocity,
+ target: r.target_velocity,
+ current: r.current_velocity,
+ hour: r.hour_of_day ?? i,
+ }))
+
+ return (
+ <>
+ {/* Summary cards */}
+
+
+
+
+
+
+
+ {/* Charts */}
+
+
+
Predicted vs Target Velocity
+
+
+
+
+ `βΉ${v}`} />
+ [`βΉ${v}`, '']} />
+
+
+
+
+
+
+
+
Forecast Status
+
+
+ `${name} (${value})`}
+ >
+ {forecastData.map((d, i) => | )}
+
+
+
+
+
+
+
+ {/* Results table */}
+
+
+
Per-Entry Results ({results.length})
+
+
+
+
+
+ #
+ Driver
+ Hour
+ Predicted βΉ/hr
+ Target βΉ/hr
+ Status
+ Progress
+ Details
+
+
+
+ {results.map((r, i) => (
+ setExpandedRow(expandedRow === i ? null : i)} />
+ ))}
+
+
+
+
+ >
+ )
+}
+
+function EarningsRow({ r, i, expanded, onToggle }) {
+ const statusColor = {
+ ahead: 'bg-green-100 text-green-700',
+ on_track: 'bg-blue-100 text-blue-700',
+ at_risk: 'bg-red-100 text-red-700',
+ }
+ return (
+ <>
+
+ {r.row_index + 1}
+ {r.driver_id || '-'}
+ {r.hour_of_day ?? '-'}
+ βΉ{r.predicted_velocity}
+ βΉ{r.target_velocity}
+
+
+ {r.forecast_status?.replace(/_/g, ' ')}
+
+
+
+
+
+
+
+ {expanded ? : }
+
+
+
+ {expanded && (
+
+
+
+
Cumulative Earnings βΉ{r.cumulative_earnings}
+
Elapsed Hours {r.elapsed_hours}h
+
Remaining βΉ{r.remaining_earnings}
+
Hours to Target {r.hours_to_target ?? 'β'}h
+
+
+
+ )}
+ >
+ )
+}
+
+
+/* ββ Shared summary card ββββββββββββββββββββββββββββββββββ */
+
+function SumCard({ label, value, icon: Icon, color = 'text-uber-black' }) {
+ return (
+
+ )
+}
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
new file mode 100644
index 0000000..2e10106
--- /dev/null
+++ b/frontend/src/pages/Dashboard.jsx
@@ -0,0 +1,112 @@
+import { useState, useEffect } from 'react'
+import { api } from '../api/client'
+import SummaryCard from '../components/SummaryCard'
+import TodayTimeline from '../components/TodayTimeline'
+import SampleTripCard from '../components/SampleTripCard'
+import EarningsProgress from '../components/EarningsProgress'
+import StressTips from '../components/StressTips'
+import { Car, Clock, DollarSign, AlertTriangle, Target, Activity } from 'lucide-react'
+
+export default function Dashboard() {
+ const [dashboard, setDashboard] = useState(null)
+ const [trips, setTrips] = useState([])
+ const [goals, setGoals] = useState(null)
+ const [selectedDay, setSelectedDay] = useState('today')
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+
+ const today = '2026-03-08'
+ const yesterday = '2026-03-07'
+
+ useEffect(() => {
+ loadData()
+ }, [selectedDay])
+
+ const loadData = async () => {
+ setLoading(true)
+ try {
+ setError('')
+ const [dash, tripRes, goalsRes] = await Promise.all([
+ api.getDashboard(),
+ api.getTrips({ date: selectedDay === 'today' ? today : yesterday }),
+ api.getGoals(),
+ ])
+ setDashboard(dash)
+ setTrips(tripRes.trips)
+ setGoals(goalsRes)
+ } catch (err) {
+ console.error('Failed to load dashboard:', err)
+ setError('Unable to load latest dashboard data. Please try again in a moment.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (loading && !dashboard) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
Dashboard
+
+ {selectedDay === 'today' ? 'Today' : 'Yesterday'}, {selectedDay === 'today' ? 'March 8, 2026' : 'March 7, 2026'}
+
+
+
+ {/* Today/Yesterday toggle */}
+
+ {['today', 'yesterday'].map(day => (
+ setSelectedDay(day)}
+ className={`px-4 py-1.5 rounded-md text-sm font-medium transition-colors capitalize ${
+ selectedDay === day
+ ? 'bg-white text-uber-black shadow-sm'
+ : 'text-uber-gray-500 hover:text-uber-gray-700'
+ }`}
+ >
+ {day}
+
+ ))}
+
+
+
+ {/* Quick Start */}
+
+
+ {/* Summary Cards */}
+ {dashboard && (
+
+
+
+
+
+
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Timeline + Earnings */}
+
+
+
+
+
+ {/* Stress Tips */}
+
+
+ )
+}
diff --git a/frontend/src/pages/Goals.jsx b/frontend/src/pages/Goals.jsx
new file mode 100644
index 0000000..2ff6226
--- /dev/null
+++ b/frontend/src/pages/Goals.jsx
@@ -0,0 +1,141 @@
+import { useState, useEffect } from 'react'
+import { api } from '../api/client'
+import EarningsProgress from '../components/EarningsProgress'
+import StressTips from '../components/StressTips'
+import { Target, Save, CheckCircle, TrendingUp, Clock, DollarSign } from 'lucide-react'
+import { clampDailyTarget, isValidMoney } from '../utils/sanityChecks'
+
+export default function Goals() {
+ const [goals, setGoals] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [targetInput, setTargetInput] = useState('')
+ const [saving, setSaving] = useState(false)
+ const [saved, setSaved] = useState(false)
+ const [error, setError] = useState('')
+
+ useEffect(() => {
+ api.getGoals()
+ .then(g => {
+ setGoals(g)
+ setTargetInput(g.daily_target)
+ })
+ .catch(() => {})
+ .finally(() => setLoading(false))
+ }, [])
+
+ const handleSave = async () => {
+ setError('')
+ const clamped = clampDailyTarget(targetInput)
+ if (!isValidMoney(clamped) || clamped <= 0) {
+ setError('Please enter a positive daily target')
+ return
+ }
+ setSaving(true)
+ try {
+ const updated = await api.setGoal(Number(clamped))
+ setGoals(updated)
+ setSaved(true)
+ setTimeout(() => setSaved(false), 2000)
+ } catch {
+ setError('Failed to save goal')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ return (
+
+
Personalization & Goals
+
+ {/* Set target */}
+
+
+
+
Daily Earnings Target
+
+
+
+
+ βΉ
+ setTargetInput(e.target.value)}
+ className="w-full pl-8 pr-4 py-3 border border-uber-gray-200 rounded-lg text-lg font-semibold outline-none focus:border-uber-blue transition"
+ placeholder="1800"
+ />
+
+
+ {saved ? (
+ <> Saved!>
+ ) : (
+ <> {saving ? 'Saving...' : 'Save Target'}>
+ )}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ Set your daily earnings target. Your progress will be tracked in real-time on the dashboard.
+
+
+
+ {/* Current progress */}
+
+
+ {/* Goal milestones */}
+ {/* {goals && (
+
+
Today's Progress
+
+ {[25, 50, 75, 100].map(pct => {
+ const amount = Math.round(goals.daily_target * pct / 100)
+ const reached = goals.current_earnings >= amount
+ return (
+
+
+ {reached ? 'β' : `${pct}%`}
+
+
+
+ βΉ{amount.toLocaleString()} milestone
+
+
+
+ {reached ? 'Reached' : 'Pending'}
+
+
+ )
+ })}
+
+
+ )} */}
+
+ {/* Stress tips */}
+
+
+ )
+}
diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx
new file mode 100644
index 0000000..8b364ac
--- /dev/null
+++ b/frontend/src/pages/Home.jsx
@@ -0,0 +1,397 @@
+import { useState, useEffect } from 'react'
+import { Mail, Lock, User, Phone, MapPin, Truck, Users, LogIn, UserPlus } from 'lucide-react'
+
+export default function Home({ onLoginSuccess }) {
+ const [mode, setMode] = useState('login') // 'login' | 'register'
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [demoUsers, setDemoUsers] = useState([])
+ const [loadingUsers, setLoadingUsers] = useState(true)
+
+ // Login form state
+ const [loginForm, setLoginForm] = useState({ username: '', password: '' })
+
+ // Register form state
+ const [registerForm, setRegisterForm] = useState({
+ username: '',
+ password: '',
+ name: '',
+ email: '',
+ phone: '',
+ city: '',
+ vehicle_type: 'Sedan',
+ vehicle_number: '',
+ shift_preference: 'morning',
+ avg_hours_per_day: 7.0,
+ avg_earnings_per_hour: 180,
+ experience_months: 0,
+ })
+
+ // Load demo users
+ useEffect(() => {
+ fetch('/api/auth/users')
+ .then(r => r.json())
+ .then(users => setDemoUsers(users))
+ .catch(() => {})
+ .finally(() => setLoadingUsers(false))
+ }, [])
+
+ const handleLogin = async (e) => {
+ e.preventDefault()
+ setError(null)
+ setLoading(true)
+
+ try {
+ const res = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ username: loginForm.username,
+ password: loginForm.password,
+ }),
+ })
+ if (!res.ok) {
+ const text = await res.text()
+ throw new Error(text || 'Login failed')
+ }
+ const user = await res.json()
+ localStorage.setItem('user', JSON.stringify(user))
+ onLoginSuccess(user)
+ } catch (err) {
+ setError(err.message || 'Login failed')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleRegister = async (e) => {
+ e.preventDefault()
+ setError(null)
+ setLoading(true)
+
+ try {
+ const res = await fetch('/api/auth/register', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(registerForm),
+ })
+ if (!res.ok) {
+ const text = await res.text()
+ throw new Error(text || 'Registration failed')
+ }
+ const user = await res.json()
+ // Auto-login after registration
+ const loginRes = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ username: registerForm.username,
+ password: registerForm.password,
+ }),
+ })
+ const fullUser = await loginRes.json()
+ localStorage.setItem('user', JSON.stringify(fullUser))
+ onLoginSuccess(fullUser)
+ } catch (err) {
+ setError(err.message || 'Registration failed')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const demoLogin = (username) => {
+ setLoginForm({ username, password: '' })
+ setError(null)
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
DrivePulse
+
Smart Stress Detection & Earnings Tracking
+
+
+
+ {/* Left side - Demo users */}
+
+
+
+
Demo Users
+
+
+ {loadingUsers ? (
+
+ ) : (
+
+ {demoUsers.map(user => (
+
demoLogin(user.username)}
+ className="w-full p-4 border-2 border-uber-gray-200 rounded-lg hover:border-uber-blue hover:bg-blue-50 transition-all text-left group"
+ >
+ {user.name}
+ @{user.username}
+
+ {user.city}
+
+ β {user.rating}
+
+
+
+ ))}
+
+ )}
+
+
+
π‘ Tip
+
+ Click on any demo user above, then click Continue to login.
+
+ Default password: password123
+
+
+
+
+ {/* Right side - Login/Register form */}
+
+ {/* Mode toggle */}
+
+ {[
+ { key: 'login', label: 'Login', icon: LogIn },
+ { key: 'register', label: 'Register', icon: UserPlus },
+ ].map(({ key, label, icon: Icon }) => (
+ { setMode(key); setError(null) }}
+ className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-md font-medium transition-colors ${
+ mode === key
+ ? 'bg-white text-uber-black shadow-sm'
+ : 'text-uber-gray-600 hover:text-uber-gray-800'
+ }`}
+ >
+
+ {label}
+
+ ))}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {mode === 'login' ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Footer */}
+
+
Your stress detection and earnings companion πβ¨
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Predict.jsx b/frontend/src/pages/Predict.jsx
new file mode 100644
index 0000000..d26924c
--- /dev/null
+++ b/frontend/src/pages/Predict.jsx
@@ -0,0 +1,449 @@
+import { useState, useEffect } from 'react'
+import {
+ Activity, DollarSign, Send, RotateCcw, AlertTriangle, CheckCircle,
+ ChevronDown, ChevronUp, Zap, Info, Loader2,
+} from 'lucide-react'
+import {
+ BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
+} from 'recharts'
+
+const SEVERITY_BG = { low: 'bg-green-50 border-green-200', medium: 'bg-yellow-50 border-yellow-200', high: 'bg-red-50 border-red-200' }
+const SEVERITY_TEXT = { low: 'text-green-700', medium: 'text-yellow-700', high: 'text-red-700' }
+const SITUATION_COLORS = {
+ NORMAL: '#06C167', TRAFFIC_STOP: '#276EF1', SPEED_BREAKER: '#FFC043',
+ CONFLICT: '#E11900', ESCALATING: '#A3000B', ARGUMENT_ONLY: '#FF6937', MUSIC_OR_CALL: '#7356BF',
+}
+const FORECAST_BG = { ahead: 'bg-green-50 border-green-200', on_track: 'bg-blue-50 border-blue-200', at_risk: 'bg-red-50 border-red-200' }
+const FORECAST_TEXT = { ahead: 'text-green-700', on_track: 'text-blue-700', at_risk: 'text-red-700' }
+
+export default function Predict() {
+ const [mode, setMode] = useState('stress')
+ const [featureDefs, setFeatureDefs] = useState([])
+ const [values, setValues] = useState({})
+ const [loading, setLoading] = useState(false)
+ const [loadingFeatures, setLoadingFeatures] = useState(true)
+ const [result, setResult] = useState(null)
+ const [error, setError] = useState(null)
+ const [collapsedGroups, setCollapsedGroups] = useState({})
+
+ // Load feature definitions when mode changes
+ useEffect(() => {
+ setLoadingFeatures(true)
+ setResult(null)
+ setError(null)
+ fetch(`/api/features/${mode}`)
+ .then(r => r.json())
+ .then(data => {
+ setFeatureDefs(data.features)
+ const defaults = {}
+ data.features.forEach(f => { defaults[f.name] = f.default })
+ setValues(defaults)
+ setCollapsedGroups({})
+ })
+ .catch(e => setError('Failed to load feature definitions'))
+ .finally(() => setLoadingFeatures(false))
+ }, [mode])
+
+ const handleChange = (name, rawVal) => {
+ setValues(prev => ({ ...prev, [name]: rawVal === '' ? '' : Number(rawVal) }))
+ }
+
+ const handleReset = () => {
+ const defaults = {}
+ featureDefs.forEach(f => { defaults[f.name] = f.default })
+ setValues(defaults)
+ setResult(null)
+ setError(null)
+ }
+
+ const handlePredict = async () => {
+ setLoading(true)
+ setError(null)
+ setResult(null)
+ try {
+ const payload = {}
+ featureDefs.forEach(f => {
+ payload[f.name] = values[f.name] === '' ? 0 : Number(values[f.name])
+ })
+ const res = await fetch(`/api/predict/${mode}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ })
+ if (!res.ok) {
+ const txt = await res.text()
+ throw new Error(txt || `Error ${res.status}`)
+ }
+ setResult(await res.json())
+ } catch (e) {
+ setError(e.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const toggleGroup = (group) => {
+ setCollapsedGroups(prev => ({ ...prev, [group]: !prev[group] }))
+ }
+
+ // Group features
+ const groups = {}
+ featureDefs.forEach(f => {
+ if (!groups[f.group]) groups[f.group] = []
+ groups[f.group].push(f)
+ })
+
+ return (
+
+ {/* Header */}
+
+
Manual Prediction
+
+ Type in sensor or earnings values directly to get a single prediction
+
+
+
+ {/* Mode toggle */}
+
+
setMode('stress')}
+ className={`flex items-center gap-2 px-5 py-2.5 rounded-full text-sm font-semibold transition-all ${
+ mode === 'stress'
+ ? 'bg-uber-black text-white shadow-lg'
+ : 'bg-uber-gray-100 text-uber-gray-600 hover:bg-uber-gray-200'
+ }`}
+ >
+ Stress Detection
+
+
setMode('earnings')}
+ className={`flex items-center gap-2 px-5 py-2.5 rounded-full text-sm font-semibold transition-all ${
+ mode === 'earnings'
+ ? 'bg-uber-black text-white shadow-lg'
+ : 'bg-uber-gray-100 text-uber-gray-600 hover:bg-uber-gray-200'
+ }`}
+ >
+ Earnings Forecast
+
+
+
+ {loadingFeatures ? (
+
+
+ Loading featuresβ¦
+
+ ) : (
+
+ {/* Input form β left 3 cols */}
+
+
+
+
+ Input Features ({featureDefs.length})
+
+
+ Reset to defaults
+
+
+
+
+ {Object.entries(groups).map(([groupName, fields]) => (
+
+ {/* Group header */}
+
toggleGroup(groupName)}
+ className="w-full flex items-center justify-between px-5 py-3 bg-uber-gray-50 hover:bg-uber-gray-100 transition-colors"
+ >
+
+ {groupName} ({fields.length})
+
+ {collapsedGroups[groupName]
+ ?
+ :
+ }
+
+
+ {/* Fields */}
+ {!collapsedGroups[groupName] && (
+
+ {fields.map(f => (
+
+
+ {f.label}
+
+ handleChange(f.name, e.target.value)}
+ className="w-full px-3 py-2 rounded-lg border border-uber-gray-200 text-sm text-uber-gray-800 focus:outline-none focus:ring-2 focus:ring-uber-blue focus:border-transparent transition-all"
+ placeholder={String(f.default)}
+ />
+
+ ))}
+
+ )}
+
+ ))}
+
+
+ {/* Submit */}
+
+
+ {loading ? : }
+ {loading ? 'Predictingβ¦' : 'Run Prediction'}
+
+
+ Reset
+
+
+
+
+ {/* Hint box */}
+
+
+
+ {mode === 'stress'
+ ? 'These are 30-second window features from accelerometer, gyroscope, and microphone sensors. Defaults represent a calm driving scenario.'
+ : 'Enter your current shift stats. The model predicts your earnings velocity (βΉ/hr) and whether you\'re on track to hit your target.'
+ }
+
+
+
+
+ {/* Result panel β right 2 cols */}
+
+ {error && (
+
+ )}
+
+ {!result && !error && (
+
+
+
+ Fill in values and hit "Run Prediction"
+
+
+ Results will appear here
+
+
+ )}
+
+ {result && mode === 'stress' &&
}
+ {result && mode === 'earnings' &&
}
+
+
+ )}
+
+ )
+}
+
+
+/* βββ Stress result card ββββββββββββββββββββββββββββββββββ */
+function StressResult({ result }) {
+ const sev = result.severity || 'low'
+ const conf = result.confidence || 0
+ const confPct = (conf * 100).toFixed(1)
+
+ // Build probability chart data
+ const probaData = result.all_probabilities
+ ? Object.entries(result.all_probabilities).map(([name, val]) => ({
+ name: name.replace(/_/g, ' '),
+ value: +(val * 100).toFixed(1),
+ fill: SITUATION_COLORS[name] || '#999',
+ }))
+ : []
+
+ return (
+
+ {/* Main result */}
+
+
+
{result.emoji}
+
+
+ {result.situation_name?.replace(/_/g, ' ')}
+
+
+ {sev.toUpperCase()} severity
+
+
+
+
+ {/* Confidence bar */}
+
+
+ Confidence
+ {confPct}%
+
+
+
= 0.85 ? 'bg-green-500' : conf >= 0.65 ? 'bg-yellow-500' : 'bg-red-400'
+ }`}
+ style={{ width: `${confPct}%` }}
+ />
+
+
+
+ {/* Flags */}
+
+ {result.should_notify && (
+
+ π Notify
+
+ )}
+ {result.is_safety_critical && (
+
+ π¨ Safety
+
+ )}
+ {!result.should_notify && !result.is_safety_critical && (
+
+ No alerts
+
+ )}
+
+
+
+ {/* Top features */}
+ {result.top_features?.length > 0 && (
+
+
Top Contributing Features
+
+ {result.top_features.map((f, i) => (
+
+
{f.feature}
+
+ z={f.z_score}
+ {f.value}
+
+
+ ))}
+
+
+ )}
+
+ {/* Probability distribution */}
+ {probaData.length > 0 && (
+
+
Class Probabilities
+
+
+
+ `${v}%`} fontSize={11} />
+
+ `${v}%`} />
+
+ {probaData.map((entry, i) => (
+ |
+ ))}
+
+
+
+
+ )}
+
+ )
+}
+
+
+/* βββ Earnings result card ββββββββββββββββββββββββββββββββ */
+function EarningsResult({ result }) {
+ if (result.error) {
+ return (
+
+ )
+ }
+
+ const vel = result.predicted_velocity
+ const forecast = result.forecast_status || 'on_track'
+ const fBg = FORECAST_BG[forecast] || FORECAST_BG.on_track
+ const fText = FORECAST_TEXT[forecast] || FORECAST_TEXT.on_track
+
+ return (
+
+ {/* Main result */}
+
+
+
+
+
+ βΉ{vel?.toFixed(2)}/hr
+
+
+ {forecast.replace(/_/g, ' ').toUpperCase()}
+
+
+
+
+ {/* Stats */}
+
+ {result.target_velocity != null && (
+
+ )}
+ {result.current_velocity != null && (
+
+ )}
+ {result.remaining_earnings != null && (
+
+ )}
+ {result.hours_to_target != null && (
+
+ )}
+
+
+
+ {/* Velocity comparison */}
+
+
Velocity Comparison
+
+
+
+ {result.target_velocity != null && (
+
+ )}
+
+
+
+ )
+}
+
+function Stat({ label, value }) {
+ return (
+
+ )
+}
+
+function VelBar({ label, value, max, color }) {
+ const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0
+ return (
+
+
+ {label}
+ βΉ{value.toFixed(0)}/hr
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Trends.jsx b/frontend/src/pages/Trends.jsx
new file mode 100644
index 0000000..02ea263
--- /dev/null
+++ b/frontend/src/pages/Trends.jsx
@@ -0,0 +1,144 @@
+import { useState, useEffect } from 'react'
+import { api } from '../api/client'
+import {
+ BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
+ ResponsiveContainer, Legend, AreaChart, Area,
+} from 'recharts'
+import { TrendingUp, TrendingDown, DollarSign, Car, AlertTriangle, Clock } from 'lucide-react'
+
+export default function Trends() {
+ const [range, setRange] = useState('7d')
+ const [metrics, setMetrics] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ setLoading(true)
+ api.getMetrics(range)
+ .then(setMetrics)
+ .catch(() => {})
+ .finally(() => setLoading(false))
+ }, [range])
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (!metrics) return null
+
+ const { days, summary } = metrics
+
+ const summaryCards = [
+ { label: 'Avg Daily Earnings', value: `βΉ${summary.avg_daily_earnings?.toLocaleString()}`, icon: DollarSign, color: 'text-uber-green' },
+ { label: 'Avg Daily Trips', value: summary.avg_daily_trips, icon: Car, color: 'text-uber-blue' },
+ { label: 'Avg Stress Score', value: summary.avg_stress, icon: AlertTriangle, color: 'text-uber-red' },
+ { label: 'Total Earnings', value: `βΉ${summary.total_earnings?.toLocaleString()}`, icon: TrendingUp, color: 'text-uber-orange' },
+ ]
+
+ return (
+
+ {/* Header + range toggle */}
+
+
Personal Trends
+
+ {[{ key: '7d', label: 'Week' }, { key: '30d', label: 'Month' }].map(r => (
+ setRange(r.key)}
+ className={`px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${
+ range === r.key
+ ? 'bg-white text-uber-black shadow-sm'
+ : 'text-uber-gray-500 hover:text-uber-gray-700'
+ }`}
+ >
+ {r.label}
+
+ ))}
+
+
+
+ {/* Summary cards */}
+
+ {summaryCards.map(({ label, value, icon: Icon, color }) => (
+
+ ))}
+
+
+ {/* Earnings chart */}
+
+
Earnings Trend
+
+
+
+
+
+
+
+
+
+
+ `βΉ${v}`} />
+ [`βΉ${v}`, 'Earnings']} />
+
+
+
+
+
+ {/* Stress + Trips chart */}
+
+
+
Average Stress
+
+
+
+
+
+
+
+
+
+
+
+
+
Trips Count
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Velocity chart */}
+
+
Earnings Velocity (βΉ/hr)
+
+
+
+
+
+
+
+
+
+
+ `βΉ${v}`} />
+ [`βΉ${v}`, 'Velocity']} />
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/TripDetail.jsx b/frontend/src/pages/TripDetail.jsx
new file mode 100644
index 0000000..9da4940
--- /dev/null
+++ b/frontend/src/pages/TripDetail.jsx
@@ -0,0 +1,216 @@
+import { useState, useEffect, useRef } from 'react'
+import { useParams, Link } from 'react-router-dom'
+import { api } from '../api/client'
+import TripMap from '../components/TripMap'
+import SignalCharts from '../components/SignalCharts'
+import TimelineSlider from '../components/TimelineSlider'
+import EventCard from '../components/EventCard'
+import ConfidenceBadge from '../components/ConfidenceBadge'
+import { ArrowLeft, Download, Clock, DollarSign, MapPin, Activity } from 'lucide-react'
+
+export default function TripDetail() {
+ const { tripId } = useParams()
+ const [trip, setTrip] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [currentSec, setCurrentSec] = useState(0)
+ const [isPlaying, setIsPlaying] = useState(false)
+ const playRef = useRef(null)
+
+ useEffect(() => {
+ api.getTrip(tripId)
+ .then(setTrip)
+ .catch(() => {})
+ .finally(() => setLoading(false))
+ }, [tripId])
+
+ // Playback
+ useEffect(() => {
+ if (isPlaying && trip) {
+ playRef.current = setInterval(() => {
+ setCurrentSec(prev => {
+ const next = prev + 10
+ if (next >= trip.duration_min * 60) {
+ setIsPlaying(false)
+ return trip.duration_min * 60
+ }
+ return next
+ })
+ }, 500)
+ }
+ return () => clearInterval(playRef.current)
+ }, [isPlaying, trip])
+
+ const handlePlayPause = () => {
+ if (currentSec >= (trip?.duration_min || 0) * 60) setCurrentSec(0)
+ setIsPlaying(!isPlaying)
+ }
+
+ const jumpTo = (sec) => {
+ setCurrentSec(sec)
+ setIsPlaying(false)
+ }
+
+ const handleExport = (format) => {
+ if (!trip) return
+ let content, filename, mime
+ if (format === 'json') {
+ content = JSON.stringify(trip, null, 2)
+ filename = `${trip.id}.json`
+ mime = 'application/json'
+ } else {
+ // CSV of signals
+ const rows = trip.signals.timestamps.map((t, i) =>
+ [t, trip.signals.speed[i], trip.signals.accel_magnitude[i], trip.signals.audio_db[i]].join(',')
+ )
+ content = 'timestamp,speed,accel_magnitude,audio_db\n' + rows.join('\n')
+ filename = `${trip.id}_signals.csv`
+ mime = 'text/csv'
+ }
+ const blob = new Blob([content], { type: mime })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename
+ a.click()
+ URL.revokeObjectURL(url)
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (!trip) {
+ return (
+
+
Trip not found
+
Back to trips
+
+ )
+ }
+
+ const maxSec = trip.duration_min * 60
+ const cursorIndex = trip.route
+ ? Math.min(Math.floor((currentSec / maxSec) * trip.route.length), trip.route.length - 1)
+ : 0
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
Trip {trip.id}
+
+ {new Date(trip.start_time).toLocaleDateString()} ·{' '}
+ {new Date(trip.start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} β{' '}
+ {new Date(trip.end_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+
+
+
+
+ {/* Export */}
+
+ handleExport('csv')}
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-uber-gray-200 text-sm hover:border-uber-gray-400 transition"
+ >
+ CSV
+
+ handleExport('json')}
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-uber-gray-200 text-sm hover:border-uber-gray-400 transition"
+ >
+ JSON
+
+
+
+
+ {/* Stats row */}
+
+
+
+
{trip.duration_min} min
+
Duration
+
+
+
+
{trip.distance_km} km
+
Distance
+
+
+
+
βΉ{trip.fare}
+
+ Fare {trip.surge_multiplier > 1 && `(${trip.surge_multiplier}Γ surge)`}
+
+
+
+
+
6 ? 'text-uber-red' :
+ trip.stress_score > 3 ? 'text-uber-yellow' : 'text-uber-green'
+ }`}>{trip.stress_score}/10
+
Stress Score
+
+
+
{trip.events_count}
+
Events
+
+
+
+ {/* Map */}
+
+
+
+
+ {/* Playback slider */}
+
+
+ {/* Signal charts + Events side by side */}
+
+
+
Sensor Signals
+
+
+
+
+ Detected Events ({trip.events.length})
+
+
+ {trip.events.map(ev => (
+
{
+ setTrip(prev => ({
+ ...prev,
+ events: prev.events.map(e =>
+ e.id === id ? { ...e, feedback: { label } } : e
+ ),
+ }))
+ }}
+ />
+ ))}
+ {trip.events.length === 0 && (
+ No events detected
+ )}
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Trips.jsx b/frontend/src/pages/Trips.jsx
new file mode 100644
index 0000000..39153a9
--- /dev/null
+++ b/frontend/src/pages/Trips.jsx
@@ -0,0 +1,493 @@
+import { useState, useEffect, useRef } from 'react'
+import { api } from '../api/client'
+import TripListItem from '../components/TripListItem'
+import FilterChips from '../components/FilterChips'
+import { Calendar, SlidersHorizontal, Plus, Upload, Download, X } from 'lucide-react'
+import { isValidMoney, isValidTimeRange } from '../utils/sanityChecks'
+
+export default function Trips() {
+ const [trips, setTrips] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [date, setDate] = useState('2026-03-08')
+ const [preset, setPreset] = useState(null)
+ const [showFilters, setShowFilters] = useState(false)
+ const [showAddTrip, setShowAddTrip] = useState(false)
+ const [creating, setCreating] = useState(false)
+ const [createError, setCreateError] = useState('')
+ const [newTrip, setNewTrip] = useState({
+ date: '2026-03-08',
+ start_time: '',
+ end_time: '',
+ distance_km: '',
+ fare: '',
+ stress_score: '',
+ })
+
+ const [importing, setImporting] = useState(false)
+ const [importError, setImportError] = useState('')
+ const [importSummary, setImportSummary] = useState(null)
+ const importInputRef = useRef(null)
+ const [filters, setFilters] = useState({
+ stress: '',
+ earnings_min: '',
+ earnings_max: '',
+ duration_min: '',
+ duration_max: '',
+ time_of_day: '',
+ })
+ const [confidenceFilter, setConfidenceFilter] = useState('')
+
+ useEffect(() => {
+ loadTrips()
+ }, [date, preset])
+
+ useEffect(() => {
+ setNewTrip(t => ({ ...t, date }))
+ }, [date])
+
+ const loadTrips = async () => {
+ setLoading(true)
+ try {
+ const params = { date }
+ if (preset) params.preset = preset
+ if (filters.stress) params.stress = filters.stress
+ if (filters.earnings_min) params.earnings_min = filters.earnings_min
+ if (filters.earnings_max) params.earnings_max = filters.earnings_max
+ if (filters.duration_min) params.duration_min = filters.duration_min
+ if (filters.duration_max) params.duration_max = filters.duration_max
+ if (filters.time_of_day) params.time_of_day = filters.time_of_day
+
+ const res = await api.getTrips(params)
+ setTrips(res.trips)
+ } catch (err) {
+ console.error('Failed to load trips:', err)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const submitNewTrip = async (e) => {
+ e.preventDefault()
+ setCreateError('')
+ // Basic client-side validation mirroring backend rules
+ if (!newTrip.date) {
+ setCreateError('Date is required')
+ return
+ }
+ if (!isValidTimeRange(newTrip.start_time, newTrip.end_time)) {
+ setCreateError('End time must be after start time')
+ return
+ }
+ if (!isValidMoney(newTrip.distance_km)) {
+ setCreateError('Please enter a valid distance')
+ return
+ }
+ if (!isValidMoney(newTrip.fare)) {
+ setCreateError('Please enter a valid fare')
+ return
+ }
+ if (newTrip.stress_score !== '' && newTrip.stress_score !== null && newTrip.stress_score !== undefined) {
+ const s = Number(newTrip.stress_score)
+ if (!Number.isFinite(s) || s < 0 || s > 10) {
+ setCreateError('Stress score must be a number between 0 and 10')
+ return
+ }
+ }
+ setCreating(true)
+ try {
+ const payload = {
+ date: newTrip.date,
+ start_time: newTrip.start_time,
+ end_time: newTrip.end_time,
+ distance_km: Number(newTrip.distance_km),
+ fare: Number(newTrip.fare),
+ }
+ if (newTrip.stress_score !== '' && newTrip.stress_score !== null && newTrip.stress_score !== undefined) {
+ payload.stress_score = Number(newTrip.stress_score)
+ }
+ await api.createTrip(payload)
+ setShowAddTrip(false)
+ setNewTrip(t => ({ ...t, start_time: '', end_time: '', distance_km: '', fare: '', stress_score: '' }))
+ await loadTrips()
+ } catch (err) {
+ setCreateError(err?.message || 'Failed to add trip')
+ } finally {
+ setCreating(false)
+ }
+ }
+
+ const handleImportTripsCsv = async (file) => {
+ if (!file) return
+ if (!file.name.toLowerCase().endsWith('.csv')) {
+ setImportError('Please upload a .csv file')
+ return
+ }
+ // 1 MB soft limit β avoids accidental huge files
+ const maxSizeBytes = 1 * 1024 * 1024
+ if (file.size > maxSizeBytes) {
+ setImportError('CSV is too large (max 1MB)')
+ return
+ }
+ setImportError('')
+ setImportSummary(null)
+ setImporting(true)
+ try {
+ const res = await api.importTripsCsv(file)
+ setImportSummary(res.summary)
+ await loadTrips()
+ } catch (err) {
+ setImportError(err?.message || 'Import failed')
+ } finally {
+ setImporting(false)
+ if (importInputRef.current) importInputRef.current.value = ''
+ }
+ }
+
+ const applyFilters = () => {
+ setPreset(null)
+ loadTrips()
+ setShowFilters(false)
+ }
+
+ const clearFilters = () => {
+ setFilters({ stress: '', earnings_min: '', earnings_max: '', duration_min: '', duration_max: '', time_of_day: '' })
+ setPreset(null)
+ setConfidenceFilter('')
+ }
+
+ // Client-side confidence filtering on events
+ let displayTrips = trips
+ if (confidenceFilter) {
+ displayTrips = trips.filter(t => {
+ if (!t.events_summary) return true
+ return t.events_summary.some(e => {
+ // We don't have confidence in summary, use stress_level as proxy
+ if (confidenceFilter === 'high') return t.stress_level === 'high'
+ if (confidenceFilter === 'medium') return t.stress_level === 'medium'
+ if (confidenceFilter === 'low') return t.stress_level === 'low'
+ return true
+ })
+ })
+ }
+
+ return (
+
+ {/* Header */}
+
+
Trip History
+
+
+
+ setDate(e.target.value)}
+ className="text-sm outline-none bg-transparent"
+ />
+
+
+ Template
+
+
importInputRef.current?.click()}
+ disabled={importing}
+ className="hidden md:flex items-center gap-2 px-3 py-2 rounded-lg border border-uber-gray-200 text-sm hover:border-uber-gray-400 transition disabled:opacity-50"
+ >
+ {importing ? 'Importingβ¦' : 'Import CSV'}
+
+
handleImportTripsCsv(e.target.files?.[0])}
+ />
+
{ setShowAddTrip(true); setCreateError('') }}
+ className="flex items-center gap-2 px-3 py-2 rounded-lg bg-uber-black text-white text-sm font-medium hover:bg-uber-gray-800 transition"
+ >
+ Add trip
+
+
setShowFilters(!showFilters)}
+ className={`p-2 rounded-lg border transition-colors ${
+ showFilters ? 'bg-uber-black text-white border-uber-black' : 'border-uber-gray-200 hover:border-uber-gray-400'
+ }`}
+ >
+
+
+
+
+
+ {/* Import feedback (mobile + status) */}
+ {(importError || importSummary) && (
+
+ {importError ? (
+
Import failed: {importError}
+ ) : (
+
+ Imported: {importSummary.created} created, {importSummary.errors} errors (from {importSummary.total_rows} rows)
+
+ )}
+
+
+ Template
+
+
importInputRef.current?.click()}
+ disabled={importing}
+ className="flex items-center gap-2 px-3 py-2 rounded-lg border border-current/20 text-sm disabled:opacity-50"
+ >
+ {importing ? 'Importingβ¦' : 'Import CSV'}
+
+
+
+ )}
+
+ {/* Quick filter presets */}
+
{ setPreset(p); clearFilters() }} />
+
+ {/* Advanced filters panel */}
+ {showFilters && (
+
+
Advanced Filters
+
+
+ Stress Level
+ setFilters(f => ({ ...f, stress: e.target.value }))}
+ className="w-full border border-uber-gray-200 rounded-lg px-3 py-2 text-sm outline-none"
+ >
+ Any
+ Low
+ Medium
+ High
+
+
+
+ Time of Day
+ setFilters(f => ({ ...f, time_of_day: e.target.value }))}
+ className="w-full border border-uber-gray-200 rounded-lg px-3 py-2 text-sm outline-none"
+ >
+ Any
+ Morning (5-12)
+ Afternoon (12-17)
+ Evening (17-21)
+ Night (21-5)
+
+
+
+ Confidence Level
+ setConfidenceFilter(e.target.value)}
+ className="w-full border border-uber-gray-200 rounded-lg px-3 py-2 text-sm outline-none"
+ >
+ Any
+ High
+ Medium
+ Low
+
+
+
+ Min Earnings (βΉ)
+ setFilters(f => ({ ...f, earnings_min: e.target.value }))}
+ className="w-full border border-uber-gray-200 rounded-lg px-3 py-2 text-sm outline-none"
+ placeholder="0"
+ />
+
+
+ Max Earnings (βΉ)
+ setFilters(f => ({ ...f, earnings_max: e.target.value }))}
+ className="w-full border border-uber-gray-200 rounded-lg px-3 py-2 text-sm outline-none"
+ placeholder="β"
+ />
+
+
+ Max Duration (min)
+ setFilters(f => ({ ...f, duration_max: e.target.value }))}
+ className="w-full border border-uber-gray-200 rounded-lg px-3 py-2 text-sm outline-none"
+ placeholder="β"
+ />
+
+
+
+
+ Apply Filters
+
+ { clearFilters(); loadTrips() }}
+ className="px-4 py-2 rounded-lg text-sm text-uber-gray-500 hover:bg-uber-gray-100"
+ >
+ Clear All
+
+
+
+ )}
+
+ {/* Results count */}
+
+ {displayTrips.length} trip{displayTrips.length !== 1 ? 's' : ''} found
+
+
+ {/* Trip list */}
+ {loading ? (
+
+ ) : displayTrips.length === 0 ? (
+
+
No trips found
+
Try a different date or adjust your filters
+
+ ) : (
+
+ {displayTrips.map(trip => (
+
+ ))}
+
+ )}
+
+ {/* Add Trip Modal */}
+ {showAddTrip && (
+
+
+
+
+
Add trip
+
Manual entry (individual trip)
+
+
setShowAddTrip(false)}
+ className="p-2 rounded-lg hover:bg-uber-gray-100 transition"
+ aria-label="Close"
+ >
+
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/utils/sanityChecks.js b/frontend/src/utils/sanityChecks.js
new file mode 100644
index 0000000..efb9bcc
--- /dev/null
+++ b/frontend/src/utils/sanityChecks.js
@@ -0,0 +1,19 @@
+export function isValidMoney(value) {
+ const n = Number(value);
+ return Number.isFinite(n) && n >= 0;
+}
+
+export function isValidTimeRange(start, end) {
+ if (!start || !end) return false;
+ // HH:MM 24h format compares correctly as strings
+ return end > start;
+}
+
+export function clampDailyTarget(target, min = 0, max = 10000) {
+ const n = Number(target);
+ if (!Number.isFinite(n)) return min;
+ if (n < min) return min;
+ if (n > max) return max;
+ return n;
+}
+
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
new file mode 100644
index 0000000..5c3431a
--- /dev/null
+++ b/frontend/tailwind.config.js
@@ -0,0 +1,32 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: ['./index.html', './src/**/*.{js,jsx}'],
+ theme: {
+ extend: {
+ colors: {
+ uber: {
+ black: '#000000',
+ white: '#FFFFFF',
+ green: '#06C167',
+ blue: '#276EF1',
+ red: '#E11900',
+ orange: '#FF6937',
+ yellow: '#FFC043',
+ gray: {
+ 50: '#F6F6F6',
+ 100: '#EEEEEE',
+ 200: '#E2E2E2',
+ 300: '#CBCBCB',
+ 400: '#AFAFAF',
+ 500: '#757575',
+ 600: '#545454',
+ 700: '#333333',
+ 800: '#1F1F1F',
+ 900: '#141414',
+ },
+ },
+ },
+ },
+ },
+ plugins: [],
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..1406734
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,15 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:8000',
+ changeOrigin: true,
+ },
+ },
+ },
+})
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000..3024d03
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,19 @@
+server {
+ listen 80;
+
+ # Serve the React SPA
+ root /usr/share/nginx/html;
+ index index.html;
+
+ location / {
+ try_files $uri /index.html;
+ }
+
+ # Proxy API calls to the backend container
+ location /api/ {
+ proxy_pass http://backend:8000/api/;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+}
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..2007904
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,6 @@
+{
+ "name": "Driver-Pulse",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {}
+}
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..0d160ca
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,10 @@
+pandas>=2.0.0
+numpy>=1.24.0
+scikit-learn>=1.3.0
+joblib>=1.3.0
+streamlit>=1.28.0
+fastapi>=0.104
+uvicorn>=0.24
+pydantic>=2.0
+python-dateutil>=2.8
+python-multipart>=0.0.6
diff --git a/tests/data/earnings_batch_example.csv b/tests/data/earnings_batch_example.csv
new file mode 100644
index 0000000..44fbaa3
--- /dev/null
+++ b/tests/data/earnings_batch_example.csv
@@ -0,0 +1,5 @@
+driver_id,timestamp,cumulative_earnings,elapsed_hours,current_velocity,target_velocity,velocity_delta,trips_completed,target_earnings
+driver-001,08:00:00,0,0.5,0,200,0,0,1800
+driver-001,09:00:00,185,1.5,185,200,-15,2,1800
+driver-001,10:00:00,420,2.5,210,200,10,4,1800
+
diff --git a/tests/data/stress_batch_example.csv b/tests/data/stress_batch_example.csv
new file mode 100644
index 0000000..1afe748
--- /dev/null
+++ b/tests/data/stress_batch_example.csv
@@ -0,0 +1,4 @@
+trip_id,timestamp,motion_max,motion_mean,motion_p95,brake_intensity,lateral_max,speed_mean,speed_at_brake,speed_drop,audio_db_max,audio_db_mean,audio_db_p90,audio_db_std,cadence_var_mean,argument_frac,loud_frac
+trip-001,08:15:00,1.2,0.6,1.1,0.8,0.5,35,35,5,68,62,67,4,0.1,0.15
+trip-002,09:30:00,5.1,1.8,4.8,4.9,2.1,40,40,25,94,82,91,8,0.72,0.95
+
diff --git a/tests/data/trips_import_example.csv b/tests/data/trips_import_example.csv
new file mode 100644
index 0000000..50af253
--- /dev/null
+++ b/tests/data/trips_import_example.csv
@@ -0,0 +1,4 @@
+date,start_time,end_time,distance_km,fare,stress_score
+2026-03-08,09:15,09:45,8.2,310,2.5
+2026-03-08,18:05,18:40,11.6,520,6.3
+