Total Projects
+{{ metrics.total_projects }}
+ Across all operation types +diff --git a/main.py b/main.py
index 4fd284f..000a0e0 100644
--- a/main.py
+++ b/main.py
@@ -10,6 +10,7 @@ from models import (
Scenario,
SimulationParameter,
)
+from routes.dashboard import router as dashboard_router
from routes.projects import router as projects_router
from routes.scenarios import router as scenarios_router
@@ -31,6 +32,7 @@ async def health() -> dict[str, str]:
return {"status": "ok"}
+app.include_router(dashboard_router)
app.include_router(projects_router)
app.include_router(scenarios_router)
diff --git a/routes/dashboard.py b/routes/dashboard.py
new file mode 100644
index 0000000..ff73c84
--- /dev/null
+++ b/routes/dashboard.py
@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+from types import SimpleNamespace
+
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+
+from dependencies import get_unit_of_work
+from models import MiningOperationType
+from services.unit_of_work import UnitOfWork
+
+router = APIRouter(tags=["Dashboard"])
+templates = Jinja2Templates(directory="templates")
+
+
+def _load_metrics(_: UnitOfWork) -> dict[str, object]:
+ today = datetime.utcnow()
+ return {
+ "total_projects": 12,
+ "active_scenarios": 7,
+ "pending_simulations": 3,
+ "last_import": today.strftime("%Y-%m-%d"),
+ }
+
+
+def _load_recent_projects(_: UnitOfWork) -> list[SimpleNamespace]:
+ now = datetime.utcnow()
+ return [
+ SimpleNamespace(
+ id=1,
+ name="Copper Ridge Expansion",
+ operation_type=MiningOperationType.OPEN_PIT,
+ updated_at=now - timedelta(days=2),
+ ),
+ SimpleNamespace(
+ id=2,
+ name="Lithium Basin North",
+ operation_type=MiningOperationType.UNDERGROUND,
+ updated_at=now - timedelta(days=5),
+ ),
+ SimpleNamespace(
+ id=3,
+ name="Nickel Underground Phase II",
+ operation_type=MiningOperationType.IN_SITU_LEACH,
+ updated_at=now - timedelta(days=9),
+ ),
+ ]
+
+
+def _load_simulation_updates(_: UnitOfWork) -> list[SimpleNamespace]:
+ now = datetime.utcnow()
+ return [
+ SimpleNamespace(
+ title="Monte Carlo Batch #21 completed",
+ description="1,000 runs processed for Lithium Basin North.",
+ timestamp=now - timedelta(hours=4),
+ ),
+ SimpleNamespace(
+ title="Scenario validation queued",
+ description="Copper Ridge Expansion pending validation on new cost inputs.",
+ timestamp=now - timedelta(days=1, hours=3),
+ ),
+ ]
+
+
+def _load_scenario_alerts(_: UnitOfWork) -> list[SimpleNamespace]:
+ return [
+ SimpleNamespace(
+ title="Variance exceeds threshold",
+ message="Nickel Underground Phase II deviates 18% from baseline forecast.",
+ link="/projects/3/view",
+ ),
+ SimpleNamespace(
+ title="Simulation backlog",
+ message="Lithium Basin North has 2 pending simulation batches.",
+ link="/projects/2/view",
+ ),
+ ]
+
+
+@router.get("/", response_class=HTMLResponse, include_in_schema=False, name="dashboard.home")
+def dashboard_home(
+ request: Request,
+ uow: UnitOfWork = Depends(get_unit_of_work),
+) -> HTMLResponse:
+ context = {
+ "request": request,
+ "metrics": _load_metrics(uow),
+ "recent_projects": _load_recent_projects(uow),
+ "simulation_updates": _load_simulation_updates(uow),
+ "scenario_alerts": _load_scenario_alerts(uow),
+ }
+ return templates.TemplateResponse("dashboard.html", context)
diff --git a/static/css/dashboard.css b/static/css/dashboard.css
new file mode 100644
index 0000000..6671c1b
--- /dev/null
+++ b/static/css/dashboard.css
@@ -0,0 +1,150 @@
+:root {
+ --dashboard-gap: 1.5rem;
+}
+
+.dashboard-header {
+ align-items: center;
+}
+
+.header-actions {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.dashboard-metrics {
+ display: grid;
+ gap: var(--dashboard-gap);
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ margin-bottom: 2rem;
+}
+
+.metric-card {
+ background: var(--card);
+ border-radius: var(--radius);
+ padding: 1.5rem;
+ box-shadow: var(--shadow);
+ border: 1px solid var(--color-border);
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.metric-card h2 {
+ margin: 0;
+ font-size: 1rem;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.metric-value {
+ font-size: 2rem;
+ font-weight: 700;
+ margin: 0;
+}
+
+.metric-caption {
+ color: var(--color-text-subtle);
+ font-size: 0.85rem;
+}
+
+.dashboard-grid {
+ display: grid;
+ gap: var(--dashboard-gap);
+ grid-template-columns: 2fr 1fr;
+ align-items: start;
+}
+
+.grid-main {
+ display: grid;
+ gap: var(--dashboard-gap);
+}
+
+.grid-sidebar {
+ display: grid;
+ gap: var(--dashboard-gap);
+}
+
+.table-link {
+ color: var(--brand-2);
+ text-decoration: none;
+}
+
+.table-link:hover,
+.table-link:focus {
+ text-decoration: underline;
+}
+
+.timeline {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.timeline-label {
+ font-size: 0.85rem;
+ color: var(--color-text-subtle);
+ display: block;
+ margin-bottom: 0.35rem;
+}
+
+.alerts-list,
+.links-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.alerts-list li {
+ padding: 0.75rem;
+ border-radius: var(--radius-sm);
+ background: rgba(209, 75, 75, 0.16);
+ border: 1px solid rgba(209, 75, 75, 0.3);
+}
+
+.links-list a {
+ color: var(--brand-3);
+ text-decoration: none;
+}
+
+.links-list a:hover,
+.links-list a:focus {
+ text-decoration: underline;
+}
+
+@media (max-width: 1024px) {
+ .dashboard-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .grid-sidebar {
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ }
+
+ .header-actions {
+ justify-content: flex-start;
+ }
+}
+
+@media (max-width: 640px) {
+ .metric-card {
+ padding: 1.25rem;
+ }
+
+ .metric-value {
+ font-size: 1.75rem;
+ }
+
+ .header-actions {
+ flex-direction: column;
+ align-items: stretch;
+ }
+}
diff --git a/templates/Dashboard.html b/templates/Dashboard.html
new file mode 100644
index 0000000..f1ff951
--- /dev/null
+++ b/templates/Dashboard.html
@@ -0,0 +1,130 @@
+{% extends "base.html" %}
+{% block title %}Dashboard · CalMiner{% endblock %}
+
+{% block head_extra %}
+
+{% endblock %}
+
+{% block content %}
+ Monitor project progress and scenario insights at a glance. {{ metrics.total_projects }} {{ metrics.active_scenarios }} {{ metrics.pending_simulations }} {{ metrics.last_import or '—' }} No recent projects. Create one now. {{ update.description }} No simulation runs yet. Configure a scenario to start simulations.Welcome back
+ Total Projects
+ Active Scenarios
+ Pending Simulations
+ Last Data Import
+ Recent Projects
+ View all
+
+
+
+ {% else %}
+
+
+
+
+ {% for project in recent_projects %}
+ Project
+ Operation
+ Updated
+
+
+ {% endfor %}
+
+
+ {{ project.name }}
+
+ {{ project.operation_type.value.replace('_', ' ') | title }}
+ {{ project.updated_at.strftime('%Y-%m-%d') }}
+ Simulation Pipeline
+
+ {% for update in simulation_updates %}
+
+ {% else %}
+