feat(metrics): Implement dashboard metrics API and integrate metrics chart components

This commit is contained in:
Abhimanyu Saharan
2026-02-04 20:49:25 +05:30
parent c3357f92d9
commit 8a41ba3f77
10 changed files with 1393 additions and 20 deletions

306
backend/app/api/metrics.py Normal file
View File

@@ -0,0 +1,306 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Literal
from fastapi import APIRouter, Depends, Query
from sqlalchemy import case, func
from sqlmodel import Session, col, select
from app.api.deps import require_admin_auth
from app.core.auth import AuthContext
from app.db.session import get_session
from app.models.activity_events import ActivityEvent
from app.models.agents import Agent
from app.models.tasks import Task
from app.schemas.metrics import (
DashboardKpis,
DashboardMetrics,
DashboardRangeSeries,
DashboardSeriesPoint,
DashboardSeriesSet,
DashboardWipPoint,
DashboardWipRangeSeries,
DashboardWipSeriesSet,
)
router = APIRouter(prefix="/metrics", tags=["metrics"])
OFFLINE_AFTER = timedelta(minutes=10)
ERROR_EVENT_PATTERN = "%failed"
@dataclass(frozen=True)
class RangeSpec:
key: Literal["24h", "7d"]
start: datetime
end: datetime
bucket: Literal["hour", "day"]
def _resolve_range(range_key: Literal["24h", "7d"]) -> RangeSpec:
now = datetime.utcnow()
if range_key == "7d":
return RangeSpec(
key="7d",
start=now - timedelta(days=7),
end=now,
bucket="day",
)
return RangeSpec(
key="24h",
start=now - timedelta(hours=24),
end=now,
bucket="hour",
)
def _comparison_range(range_key: Literal["24h", "7d"]) -> RangeSpec:
return _resolve_range("7d" if range_key == "24h" else "24h")
def _bucket_start(value: datetime, bucket: Literal["hour", "day"]) -> datetime:
if bucket == "day":
return value.replace(hour=0, minute=0, second=0, microsecond=0)
return value.replace(minute=0, second=0, microsecond=0)
def _build_buckets(range_spec: RangeSpec) -> list[datetime]:
cursor = _bucket_start(range_spec.start, range_spec.bucket)
step = timedelta(days=1) if range_spec.bucket == "day" else timedelta(hours=1)
buckets: list[datetime] = []
while cursor <= range_spec.end:
buckets.append(cursor)
cursor += step
return buckets
def _series_from_mapping(
range_spec: RangeSpec, mapping: dict[datetime, float]
) -> DashboardRangeSeries:
points = [
DashboardSeriesPoint(period=bucket, value=float(mapping.get(bucket, 0)))
for bucket in _build_buckets(range_spec)
]
return DashboardRangeSeries(
range=range_spec.key,
bucket=range_spec.bucket,
points=points,
)
def _wip_series_from_mapping(
range_spec: RangeSpec, mapping: dict[datetime, dict[str, int]]
) -> DashboardWipRangeSeries:
points: list[DashboardWipPoint] = []
for bucket in _build_buckets(range_spec):
values = mapping.get(bucket, {})
points.append(
DashboardWipPoint(
period=bucket,
inbox=values.get("inbox", 0),
in_progress=values.get("in_progress", 0),
review=values.get("review", 0),
)
)
return DashboardWipRangeSeries(
range=range_spec.key,
bucket=range_spec.bucket,
points=points,
)
def _query_throughput(session: Session, range_spec: RangeSpec) -> DashboardRangeSeries:
bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
statement = (
select(bucket_col, func.count(Task.id))
.where(col(Task.status) == "review")
.where(col(Task.updated_at) >= range_spec.start)
.where(col(Task.updated_at) <= range_spec.end)
.group_by(bucket_col)
.order_by(bucket_col)
)
results = session.exec(statement).all()
mapping = {row[0]: float(row[1]) for row in results}
return _series_from_mapping(range_spec, mapping)
def _query_cycle_time(session: Session, range_spec: RangeSpec) -> DashboardRangeSeries:
bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
duration_hours = func.extract(
"epoch", Task.updated_at - Task.in_progress_at
) / 3600.0
statement = (
select(bucket_col, func.avg(duration_hours))
.where(col(Task.status) == "review")
.where(col(Task.in_progress_at).is_not(None))
.where(col(Task.updated_at) >= range_spec.start)
.where(col(Task.updated_at) <= range_spec.end)
.group_by(bucket_col)
.order_by(bucket_col)
)
results = session.exec(statement).all()
mapping = {row[0]: float(row[1] or 0) for row in results}
return _series_from_mapping(range_spec, mapping)
def _query_error_rate(session: Session, range_spec: RangeSpec) -> DashboardRangeSeries:
bucket_col = func.date_trunc(range_spec.bucket, ActivityEvent.created_at).label(
"bucket"
)
error_case = case(
(
col(ActivityEvent.event_type).like(ERROR_EVENT_PATTERN),
1,
),
else_=0,
)
statement = (
select(bucket_col, func.sum(error_case), func.count(ActivityEvent.id))
.where(col(ActivityEvent.created_at) >= range_spec.start)
.where(col(ActivityEvent.created_at) <= range_spec.end)
.group_by(bucket_col)
.order_by(bucket_col)
)
results = session.exec(statement).all()
mapping: dict[datetime, float] = {}
for bucket, errors, total in results:
total_count = float(total or 0)
error_count = float(errors or 0)
rate = (error_count / total_count) * 100 if total_count > 0 else 0.0
mapping[bucket] = rate
return _series_from_mapping(range_spec, mapping)
def _query_wip(session: Session, range_spec: RangeSpec) -> DashboardWipRangeSeries:
bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
inbox_case = case((col(Task.status) == "inbox", 1), else_=0)
progress_case = case((col(Task.status) == "in_progress", 1), else_=0)
review_case = case((col(Task.status) == "review", 1), else_=0)
statement = (
select(
bucket_col,
func.sum(inbox_case),
func.sum(progress_case),
func.sum(review_case),
)
.where(col(Task.updated_at) >= range_spec.start)
.where(col(Task.updated_at) <= range_spec.end)
.group_by(bucket_col)
.order_by(bucket_col)
)
results = session.exec(statement).all()
mapping: dict[datetime, dict[str, int]] = {}
for bucket, inbox, in_progress, review in results:
mapping[bucket] = {
"inbox": int(inbox or 0),
"in_progress": int(in_progress or 0),
"review": int(review or 0),
}
return _wip_series_from_mapping(range_spec, mapping)
def _median_cycle_time_7d(session: Session) -> float | None:
now = datetime.utcnow()
start = now - timedelta(days=7)
duration_hours = func.extract(
"epoch", Task.updated_at - Task.in_progress_at
) / 3600.0
statement = (
select(func.percentile_cont(0.5).within_group(duration_hours))
.where(col(Task.status) == "review")
.where(col(Task.in_progress_at).is_not(None))
.where(col(Task.updated_at) >= start)
.where(col(Task.updated_at) <= now)
)
value = session.exec(statement).one_or_none()
if value is None:
return None
if isinstance(value, tuple):
value = value[0]
if value is None:
return None
return float(value)
def _error_rate_kpi(session: Session, range_spec: RangeSpec) -> float:
error_case = case(
(
col(ActivityEvent.event_type).like(ERROR_EVENT_PATTERN),
1,
),
else_=0,
)
statement = (
select(func.sum(error_case), func.count(ActivityEvent.id))
.where(col(ActivityEvent.created_at) >= range_spec.start)
.where(col(ActivityEvent.created_at) <= range_spec.end)
)
result = session.exec(statement).one_or_none()
if result is None:
return 0.0
errors, total = result
total_count = float(total or 0)
error_count = float(errors or 0)
return (error_count / total_count) * 100 if total_count > 0 else 0.0
def _active_agents(session: Session) -> int:
threshold = datetime.utcnow() - OFFLINE_AFTER
statement = select(func.count(Agent.id)).where(
col(Agent.last_seen_at).is_not(None),
col(Agent.last_seen_at) >= threshold,
)
result = session.exec(statement).one()
return int(result)
def _tasks_in_progress(session: Session) -> int:
statement = select(func.count(Task.id)).where(col(Task.status) == "in_progress")
result = session.exec(statement).one()
return int(result)
@router.get("/dashboard", response_model=DashboardMetrics)
def dashboard_metrics(
range: Literal["24h", "7d"] = Query(default="24h"),
session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> DashboardMetrics:
primary = _resolve_range(range)
comparison = _comparison_range(range)
throughput = DashboardSeriesSet(
primary=_query_throughput(session, primary),
comparison=_query_throughput(session, comparison),
)
cycle_time = DashboardSeriesSet(
primary=_query_cycle_time(session, primary),
comparison=_query_cycle_time(session, comparison),
)
error_rate = DashboardSeriesSet(
primary=_query_error_rate(session, primary),
comparison=_query_error_rate(session, comparison),
)
wip = DashboardWipSeriesSet(
primary=_query_wip(session, primary),
comparison=_query_wip(session, comparison),
)
kpis = DashboardKpis(
active_agents=_active_agents(session),
tasks_in_progress=_tasks_in_progress(session),
error_rate_pct=_error_rate_kpi(session, primary),
median_cycle_time_hours_7d=_median_cycle_time_7d(session),
)
return DashboardMetrics(
range=primary.key,
generated_at=datetime.utcnow(),
kpis=kpis,
throughput=throughput,
cycle_time=cycle_time,
error_rate=error_rate,
wip=wip,
)

View File

@@ -8,6 +8,7 @@ from app.api.agents import router as agents_router
from app.api.auth import router as auth_router
from app.api.boards import router as boards_router
from app.api.gateway import router as gateway_router
from app.api.metrics import router as metrics_router
from app.api.tasks import router as tasks_router
from app.api.users import router as users_router
from app.core.config import settings
@@ -54,6 +55,7 @@ api_v1.include_router(auth_router)
api_v1.include_router(agents_router)
api_v1.include_router(activity_router)
api_v1.include_router(gateway_router)
api_v1.include_router(metrics_router)
api_v1.include_router(boards_router)
api_v1.include_router(tasks_router)
api_v1.include_router(users_router)

View File

@@ -1,6 +1,7 @@
from app.schemas.activity_events import ActivityEventRead
from app.schemas.agents import AgentCreate, AgentRead, AgentUpdate
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
from app.schemas.metrics import DashboardMetrics
from app.schemas.tasks import TaskCreate, TaskRead, TaskUpdate
from app.schemas.users import UserCreate, UserRead, UserUpdate
@@ -12,6 +13,7 @@ __all__ = [
"BoardCreate",
"BoardRead",
"BoardUpdate",
"DashboardMetrics",
"TaskCreate",
"TaskRead",
"TaskUpdate",

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from sqlmodel import SQLModel
class DashboardSeriesPoint(SQLModel):
period: datetime
value: float
class DashboardWipPoint(SQLModel):
period: datetime
inbox: int
in_progress: int
review: int
class DashboardRangeSeries(SQLModel):
range: Literal["24h", "7d"]
bucket: Literal["hour", "day"]
points: list[DashboardSeriesPoint]
class DashboardWipRangeSeries(SQLModel):
range: Literal["24h", "7d"]
bucket: Literal["hour", "day"]
points: list[DashboardWipPoint]
class DashboardSeriesSet(SQLModel):
primary: DashboardRangeSeries
comparison: DashboardRangeSeries
class DashboardWipSeriesSet(SQLModel):
primary: DashboardWipRangeSeries
comparison: DashboardWipRangeSeries
class DashboardKpis(SQLModel):
active_agents: int
tasks_in_progress: int
error_rate_pct: float
median_cycle_time_hours_7d: float | None
class DashboardMetrics(SQLModel):
range: Literal["24h", "7d"]
generated_at: datetime
kpis: DashboardKpis
throughput: DashboardSeriesSet
cycle_time: DashboardSeriesSet
error_rate: DashboardSeriesSet
wip: DashboardWipSeriesSet

View File

@@ -19,7 +19,8 @@
"cmdk": "^1.1.1",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"recharts": "^3.7.0"
},
"devDependencies": {
"@types/node": "^20",
@@ -2684,6 +2685,42 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2900,6 +2937,18 @@
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -2979,6 +3028,69 @@
"tslib": "^2.4.0"
}
},
"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",
@@ -3047,6 +3159,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.54.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz",
@@ -4265,7 +4383,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -4373,6 +4490,127 @@
"devOptional": true,
"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/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -4452,6 +4690,12 @@
}
}
},
"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/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -4780,6 +5024,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@@ -5269,6 +5523,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/execa": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
@@ -5938,6 +6198,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -5980,6 +6250,15 @@
"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-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -7792,9 +8071,31 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -7887,6 +8188,51 @@
"node": ">=8.10.0"
}
},
"node_modules/recharts": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -7951,6 +8297,12 @@
"node": ">=0.10.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -8797,6 +9149,12 @@
"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",
@@ -9327,6 +9685,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"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/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -12,8 +12,8 @@
},
"dependencies": {
"@clerk/nextjs": "^6.37.1",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.2",
@@ -22,7 +22,8 @@
"cmdk": "^1.1.1",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"recharts": "^3.7.0"
},
"devDependencies": {
"@types/node": "^20",

View File

@@ -1,22 +1,319 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import MetricSparkline from "@/components/charts/metric-sparkline";
import { getApiBaseUrl } from "@/lib/api-base";
type RangeKey = "24h" | "7d";
type BucketKey = "hour" | "day";
type SeriesPoint = {
period: string;
value: number;
};
type WipPoint = {
period: string;
inbox: number;
in_progress: number;
review: number;
};
type RangeSeries = {
range: RangeKey;
bucket: BucketKey;
points: SeriesPoint[];
};
type WipRangeSeries = {
range: RangeKey;
bucket: BucketKey;
points: WipPoint[];
};
type SeriesSet = {
primary: RangeSeries;
comparison: RangeSeries;
};
type WipSeriesSet = {
primary: WipRangeSeries;
comparison: WipRangeSeries;
};
type DashboardMetrics = {
range: RangeKey;
generated_at: string;
kpis: {
active_agents: number;
tasks_in_progress: number;
error_rate_pct: number;
median_cycle_time_hours_7d: number | null;
};
throughput: SeriesSet;
cycle_time: SeriesSet;
error_rate: SeriesSet;
wip: WipSeriesSet;
};
const apiBase = getApiBaseUrl();
const hourFormatter = new Intl.DateTimeFormat("en-US", { hour: "numeric" });
const dayFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
});
const updatedFormatter = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
});
const formatPeriod = (value: string, bucket: BucketKey) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return bucket === "hour" ? hourFormatter.format(date) : dayFormatter.format(date);
};
const formatNumber = (value: number) => value.toLocaleString("en-US");
const formatPercent = (value: number) => `${value.toFixed(1)}%`;
const formatHours = (value: number | null) =>
value === null || !Number.isFinite(value) ? "--" : `${value.toFixed(1)}h`;
function buildSeries(series: RangeSeries) {
return series.points.map((point) => ({
period: formatPeriod(point.period, series.bucket),
value: Number(point.value ?? 0),
}));
}
function buildWipSeries(series: WipRangeSeries) {
return series.points.map((point) => ({
period: formatPeriod(point.period, series.bucket),
inbox: Number(point.inbox ?? 0),
in_progress: Number(point.in_progress ?? 0),
review: Number(point.review ?? 0),
}));
}
function buildSparkline(series: RangeSeries) {
return {
values: series.points.map((point) => Number(point.value ?? 0)),
labels: series.points.map((point) => formatPeriod(point.period, series.bucket)),
};
}
function buildWipSparkline(series: WipRangeSeries, key: keyof WipPoint) {
return {
values: series.points.map((point) => Number(point[key] ?? 0)),
labels: series.points.map((point) => formatPeriod(point.period, series.bucket)),
};
}
type TooltipProps = {
active?: boolean;
payload?: Array<{ value?: number; name?: string; color?: string }>;
label?: string;
formatter?: (value: number, name?: string) => string;
};
function TooltipCard({ active, payload, label, formatter }: TooltipProps) {
if (!active || !payload?.length) return null;
return (
<div className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs text-slate-700 shadow-lg">
<div className="text-slate-500">{label}</div>
<div className="mt-1 space-y-1">
{payload.map((entry) => (
<div key={entry.name} className="flex items-center justify-between gap-3">
<span className="flex items-center gap-2">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
{entry.name}
</span>
<span className="font-semibold text-slate-900">
{formatter ? formatter(Number(entry.value ?? 0), entry.name) : entry.value}
</span>
</div>
))}
</div>
</div>
);
}
function KpiCard({
label,
value,
sublabel,
sparkline,
}: {
label: string;
value: string;
sublabel?: string;
sparkline?: { values: number[]; labels: string[] };
}) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
{label}
</div>
<div className="mt-2 text-3xl font-semibold text-slate-900">{value}</div>
{sublabel ? (
<div className="mt-2 text-xs text-slate-500">{sublabel}</div>
) : null}
{sparkline ? (
<div className="mt-4">
<MetricSparkline
values={sparkline.values}
labels={sparkline.labels}
bucket="week"
/>
</div>
) : null}
</div>
);
}
function ChartCard({
title,
subtitle,
children,
sparkline,
}: {
title: string;
subtitle: string;
children: React.ReactNode;
sparkline?: { values: number[]; labels: string[] };
}) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<div className="flex items-start justify-between">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-slate-500">
{subtitle}
</div>
<div className="mt-1 text-lg font-semibold text-slate-900">{title}</div>
</div>
<div className="text-xs text-slate-500">24h</div>
</div>
<div className="mt-4 h-56">{children}</div>
{sparkline ? (
<div className="mt-4">
<div className="text-xs text-slate-500">7d trend</div>
<MetricSparkline
values={sparkline.values}
labels={sparkline.labels}
bucket="week"
/>
</div>
) : null}
</div>
);
}
export default function DashboardPage() {
const router = useRouter();
const { getToken, isSignedIn } = useAuth();
const [metrics, setMetrics] = useState<DashboardMetrics | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadMetrics = async () => {
if (!isSignedIn) return;
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const response = await fetch(
`${apiBase}/api/v1/metrics/dashboard?range=24h`,
{
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
},
);
if (!response.ok) {
throw new Error("Unable to load dashboard metrics.");
}
const data = (await response.json()) as DashboardMetrics;
setMetrics(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadMetrics();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn]);
const throughputSeries = useMemo(
() => (metrics ? buildSeries(metrics.throughput.primary) : []),
[metrics],
);
const cycleSeries = useMemo(
() => (metrics ? buildSeries(metrics.cycle_time.primary) : []),
[metrics],
);
const errorSeries = useMemo(
() => (metrics ? buildSeries(metrics.error_rate.primary) : []),
[metrics],
);
const wipSeries = useMemo(
() => (metrics ? buildWipSeries(metrics.wip.primary) : []),
[metrics],
);
const throughputSpark = useMemo(
() => (metrics ? buildSparkline(metrics.throughput.comparison) : null),
[metrics],
);
const cycleSpark = useMemo(
() => (metrics ? buildSparkline(metrics.cycle_time.comparison) : null),
[metrics],
);
const errorSpark = useMemo(
() => (metrics ? buildSparkline(metrics.error_rate.comparison) : null),
[metrics],
);
const wipSpark = useMemo(
() => (metrics ? buildWipSparkline(metrics.wip.comparison, "in_progress") : null),
[metrics],
);
const updatedAtLabel = useMemo(() => {
if (!metrics?.generated_at) return null;
const date = new Date(metrics.generated_at);
if (Number.isNaN(date.getTime())) return null;
return updatedFormatter.format(date);
}, [metrics]);
return (
<DashboardShell>
<SignedOut>
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-2xl surface-panel p-10 text-center">
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-2xl surface-panel p-10 text-center lg:col-span-2">
<p className="text-sm text-muted">
Sign in to access your dashboard.
Sign in to access the dashboard.
</p>
<SignInButton
mode="modal"
@@ -29,13 +326,205 @@ export default function DashboardPage() {
</SignedOut>
<SignedIn>
<DashboardSidebar />
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-2xl surface-panel p-10 text-center">
<p className="text-sm text-muted">
Your work lives in boards. Jump in to manage tasks.
</p>
<Button onClick={() => router.push("/boards")}>
Go to boards
</Button>
<div className="flex h-full flex-col gap-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-strong">Dashboard</h2>
{updatedAtLabel ? (
<div className="text-xs text-muted">Updated {updatedAtLabel}</div>
) : null}
</div>
{error ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-600">
{error}
</div>
) : null}
{isLoading && !metrics ? (
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-500 shadow-sm">
Loading dashboard metrics
</div>
) : null}
{metrics ? (
<>
<div className="grid gap-4 lg:grid-cols-4">
<KpiCard
label="Active agents"
value={formatNumber(metrics.kpis.active_agents)}
sublabel="Last 10 minutes"
/>
<KpiCard
label="Tasks in progress"
value={formatNumber(metrics.kpis.tasks_in_progress)}
sublabel="Current WIP"
sparkline={wipSpark ?? undefined}
/>
<KpiCard
label="Error rate"
value={formatPercent(metrics.kpis.error_rate_pct)}
sublabel="24h average"
sparkline={errorSpark ?? undefined}
/>
<KpiCard
label="Median cycle time"
value={formatHours(metrics.kpis.median_cycle_time_hours_7d)}
sublabel="7d median"
sparkline={cycleSpark ?? undefined}
/>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<ChartCard
title="Throughput"
subtitle="Completed tasks"
sparkline={throughputSpark ?? undefined}
>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={throughputSeries} margin={{ left: 4, right: 12 }}>
<CartesianGrid vertical={false} stroke="#e5e7eb" />
<XAxis
dataKey="period"
tickLine={false}
axisLine={false}
tick={{ fill: "#6b7280", fontSize: 12 }}
/>
<YAxis
tickLine={false}
axisLine={false}
tick={{ fill: "#6b7280", fontSize: 12 }}
width={40}
/>
<Tooltip content={<TooltipCard formatter={(v) => formatNumber(v)} />} />
<Bar dataKey="value" name="Completed" fill="#2563eb" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard
title="Cycle time"
subtitle="Avg hours to review"
sparkline={cycleSpark ?? undefined}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={cycleSeries} margin={{ left: 4, right: 12 }}>
<CartesianGrid vertical={false} stroke="#e5e7eb" />
<XAxis
dataKey="period"
tickLine={false}
axisLine={false}
tick={{ fill: "#6b7280", fontSize: 12 }}
/>
<YAxis
tickLine={false}
axisLine={false}
tick={{ fill: "#6b7280", fontSize: 12 }}
width={40}
/>
<Tooltip
content={<TooltipCard formatter={(v) => `${v.toFixed(1)}h`} />}
/>
<Line
type="monotone"
dataKey="value"
name="Hours"
stroke="#16a34a"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard
title="Error rate"
subtitle="Failed events"
sparkline={errorSpark ?? undefined}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={errorSeries} margin={{ left: 4, right: 12 }}>
<CartesianGrid vertical={false} stroke="#e5e7eb" />
<XAxis
dataKey="period"
tickLine={false}
axisLine={false}
tick={{ fill: "#6b7280", fontSize: 12 }}
/>
<YAxis
tickLine={false}
axisLine={false}
tick={{ fill: "#6b7280", fontSize: 12 }}
width={40}
/>
<Tooltip
content={<TooltipCard formatter={(v) => formatPercent(v)} />}
/>
<Line
type="monotone"
dataKey="value"
name="Error rate"
stroke="#dc2626"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard
title="Work in progress"
subtitle="Status distribution"
sparkline={wipSpark ?? undefined}
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={wipSeries} margin={{ left: 4, right: 12 }}>
<CartesianGrid vertical={false} stroke="#e5e7eb" />
<XAxis
dataKey="period"
tickLine={false}
axisLine={false}
tick={{ fill: "#6b7280", fontSize: 12 }}
/>
<YAxis
tickLine={false}
axisLine={false}
tick={{ fill: "#6b7280", fontSize: 12 }}
width={40}
/>
<Tooltip content={<TooltipCard formatter={(v) => formatNumber(v)} />} />
<Area
type="monotone"
dataKey="inbox"
name="Inbox"
stackId="wip"
fill="#cbd5f5"
stroke="#94a3b8"
fillOpacity={0.7}
/>
<Area
type="monotone"
dataKey="in_progress"
name="In progress"
stackId="wip"
fill="#93c5fd"
stroke="#2563eb"
fillOpacity={0.7}
/>
<Area
type="monotone"
dataKey="review"
name="Review"
stackId="wip"
fill="#86efac"
stroke="#16a34a"
fillOpacity={0.7}
/>
</AreaChart>
</ResponsiveContainer>
</ChartCard>
</div>
</>
) : null}
</div>
</SignedIn>
</DashboardShell>

View File

@@ -93,7 +93,7 @@ export default function OnboardingPage() {
setName(data.preferred_name ?? data.name ?? fallbackName);
setTimezone(data.timezone ?? "");
if (isCompleteProfile(data)) {
router.replace("/boards");
router.replace("/dashboard");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
@@ -145,7 +145,7 @@ export default function OnboardingPage() {
if (!response.ok) {
throw new Error("Unable to update profile.");
}
router.replace("/boards");
router.replace("/dashboard");
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {

View File

@@ -0,0 +1,125 @@
"use client";
import {
Area,
AreaChart,
ResponsiveContainer,
type TooltipContentProps,
Tooltip,
YAxis,
} from "recharts";
import { useId } from "react";
import { cn } from "@/lib/utils";
type MetricSparklineProps = {
values: number[];
bucket?: string;
labels?: string[];
className?: string;
};
const buildSparkData = (values: number[]) =>
values.map((value, index) => ({
index,
value,
}));
const formatSparkValue = (value: number) => {
if (!Number.isFinite(value)) {
return "--";
}
const rounded = Math.round(value * 10) / 10;
return Number.isInteger(rounded) ? rounded.toString() : rounded.toFixed(1);
};
const SparklineTooltip = ({
active,
payload,
bucket,
labels,
}: TooltipContentProps<number, string> & {
bucket?: string;
labels?: string[];
}) => {
if (!active || !payload?.length) {
return null;
}
const entry = payload[0];
const rawValue = entry?.value;
if (typeof rawValue !== "number") {
return null;
}
const dayIndex =
typeof entry.payload?.index === "number" ? entry.payload.index + 1 : null;
const labelIndex =
typeof entry.payload?.index === "number" ? entry.payload.index : null;
const resolvedLabel = labelIndex !== null ? labels?.[labelIndex] : undefined;
const label =
bucket === "week"
? "Week"
: bucket === "month"
? "Month"
: bucket === "year"
? "Year"
: "Day";
const prefix = resolvedLabel ?? (dayIndex ? `${label} ${dayIndex}` : "");
return (
<div className="rounded-md border border-gray-200 bg-white px-2 py-1 text-xs font-medium text-gray-700 shadow-sm">
{prefix ? `${prefix}: ` : ""}
{formatSparkValue(rawValue)}
</div>
);
};
export default function MetricSparkline({
values,
bucket,
labels,
className,
}: MetricSparklineProps) {
const gradientId = useId();
if (!values.length) {
return null;
}
const data = buildSparkData(values);
const strokeColor = "#60a5fa";
const fillColor = "#bfdbfe";
return (
<div className={cn("h-8 w-full", className)}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={data}
margin={{ top: 4, right: 0, bottom: 0, left: 0 }}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={fillColor} stopOpacity={0.35} />
<stop offset="100%" stopColor={fillColor} stopOpacity={0} />
</linearGradient>
</defs>
<YAxis hide domain={["dataMin", "dataMax"]} />
<Tooltip<number, string>
cursor={false}
content={(props) => (
<SparklineTooltip {...props} bucket={bucket} labels={labels} />
)}
/>
<Area
type="monotone"
dataKey="value"
stroke={strokeColor}
strokeWidth={1.75}
fill={`url(#${gradientId})`}
fillOpacity={1}
dot={false}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -2,7 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Bot, LayoutGrid } from "lucide-react";
import { BarChart3, Bot, LayoutGrid } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -16,6 +16,17 @@ export function DashboardSidebar() {
Navigation
</p>
<nav className="space-y-2 text-sm">
<Link
href="/dashboard"
className={cn(
"flex items-center gap-3 rounded-xl border border-transparent px-3 py-2 font-semibold text-muted transition hover:border-[color:var(--border)] hover:bg-[color:var(--surface-muted)]",
pathname === "/dashboard" &&
"border-[color:var(--accent-soft)] bg-[color:var(--accent-soft)] text-[color:var(--accent-strong)]"
)}
>
<BarChart3 className="h-4 w-4" />
Dashboard
</Link>
<Link
href="/boards"
className={cn(