feat(metrics): extend time range options and update related metrics handling

This commit is contained in:
Abhimanyu Saharan
2026-02-12 19:29:58 +05:30
parent a2ab8b43b3
commit 8bd606a8dc
11 changed files with 368 additions and 127 deletions

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Literal
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
@@ -21,8 +20,10 @@ from app.models.activity_events import ActivityEvent
from app.models.agents import Agent from app.models.agents import Agent
from app.models.tasks import Task from app.models.tasks import Task
from app.schemas.metrics import ( from app.schemas.metrics import (
DashboardBucketKey,
DashboardKpis, DashboardKpis,
DashboardMetrics, DashboardMetrics,
DashboardRangeKey,
DashboardRangeSeries, DashboardRangeSeries,
DashboardSeriesPoint, DashboardSeriesPoint,
DashboardSeriesSet, DashboardSeriesSet,
@@ -34,7 +35,6 @@ from app.services.organizations import OrganizationContext, list_accessible_boar
router = APIRouter(prefix="/metrics", tags=["metrics"]) router = APIRouter(prefix="/metrics", tags=["metrics"])
OFFLINE_AFTER = timedelta(minutes=10)
ERROR_EVENT_PATTERN = "%failed" ERROR_EVENT_PATTERN = "%failed"
_RUNTIME_TYPE_REFERENCES = (UUID, AsyncSession) _RUNTIME_TYPE_REFERENCES = (UUID, AsyncSession)
RANGE_QUERY = Query(default="24h") RANGE_QUERY = Query(default="24h")
@@ -46,46 +46,77 @@ ORG_MEMBER_DEP = Depends(require_org_member)
class RangeSpec: class RangeSpec:
"""Resolved time-range specification for metric aggregation.""" """Resolved time-range specification for metric aggregation."""
key: Literal["24h", "7d"] key: DashboardRangeKey
start: datetime start: datetime
end: datetime end: datetime
bucket: Literal["hour", "day"] bucket: DashboardBucketKey
duration: timedelta
def _resolve_range(range_key: Literal["24h", "7d"]) -> RangeSpec: def _resolve_range(range_key: DashboardRangeKey) -> RangeSpec:
now = utcnow() now = utcnow()
if range_key == "7d": specs: dict[DashboardRangeKey, tuple[timedelta, DashboardBucketKey]] = {
return RangeSpec( "24h": (timedelta(hours=24), "hour"),
key="7d", "3d": (timedelta(days=3), "day"),
start=now - timedelta(days=7), "7d": (timedelta(days=7), "day"),
end=now, "14d": (timedelta(days=14), "day"),
bucket="day", "1m": (timedelta(days=30), "day"),
) "3m": (timedelta(days=90), "week"),
"6m": (timedelta(days=180), "week"),
"1y": (timedelta(days=365), "month"),
}
duration, bucket = specs[range_key]
return RangeSpec( return RangeSpec(
key="24h", key=range_key,
start=now - timedelta(hours=24), start=now - duration,
end=now, end=now,
bucket="hour", bucket=bucket,
duration=duration,
) )
def _comparison_range(range_key: Literal["24h", "7d"]) -> RangeSpec: def _comparison_range(range_spec: RangeSpec) -> RangeSpec:
return _resolve_range("7d" if range_key == "24h" else "24h") return RangeSpec(
key=range_spec.key,
start=range_spec.start - range_spec.duration,
end=range_spec.end - range_spec.duration,
bucket=range_spec.bucket,
duration=range_spec.duration,
)
def _bucket_start(value: datetime, bucket: Literal["hour", "day"]) -> datetime: def _bucket_start(value: datetime, bucket: DashboardBucketKey) -> datetime:
normalized = value.replace(hour=0, minute=0, second=0, microsecond=0)
if bucket == "month":
return normalized.replace(day=1)
if bucket == "week":
return normalized - timedelta(days=normalized.weekday())
if bucket == "day": if bucket == "day":
return value.replace(hour=0, minute=0, second=0, microsecond=0) return normalized
return value.replace(minute=0, second=0, microsecond=0) return value.replace(minute=0, second=0, microsecond=0)
def _next_bucket(cursor: datetime, bucket: DashboardBucketKey) -> datetime:
if bucket == "hour":
return cursor + timedelta(hours=1)
if bucket == "day":
return cursor + timedelta(days=1)
if bucket == "week":
return cursor + timedelta(days=7)
next_month = cursor.month + 1
next_year = cursor.year
if next_month > 12:
next_month = 1
next_year += 1
return cursor.replace(year=next_year, month=next_month, day=1)
def _build_buckets(range_spec: RangeSpec) -> list[datetime]: def _build_buckets(range_spec: RangeSpec) -> list[datetime]:
cursor = _bucket_start(range_spec.start, range_spec.bucket) 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] = [] buckets: list[datetime] = []
while cursor <= range_spec.end: while cursor <= range_spec.end:
buckets.append(cursor) buckets.append(cursor)
cursor += step cursor = _next_bucket(cursor, range_spec.bucket)
return buckets return buckets
@@ -117,6 +148,7 @@ def _wip_series_from_mapping(
inbox=values.get("inbox", 0), inbox=values.get("inbox", 0),
in_progress=values.get("in_progress", 0), in_progress=values.get("in_progress", 0),
review=values.get("review", 0), review=values.get("review", 0),
done=values.get("done", 0),
), ),
) )
return DashboardWipRangeSeries( return DashboardWipRangeSeries(
@@ -215,50 +247,69 @@ async def _query_wip(
range_spec: RangeSpec, range_spec: RangeSpec,
board_ids: list[UUID], board_ids: list[UUID],
) -> DashboardWipRangeSeries: ) -> DashboardWipRangeSeries:
bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket") if not board_ids:
inbox_case = case((col(Task.status) == "inbox", 1), else_=0) return _wip_series_from_mapping(range_spec, {})
inbox_bucket_col = func.date_trunc(range_spec.bucket, Task.created_at).label(
"inbox_bucket"
)
inbox_statement = (
select(inbox_bucket_col, func.count())
.where(col(Task.status) == "inbox")
.where(col(Task.created_at) >= range_spec.start)
.where(col(Task.created_at) <= range_spec.end)
.where(col(Task.board_id).in_(board_ids))
.group_by(inbox_bucket_col)
.order_by(inbox_bucket_col)
)
inbox_results = (await session.exec(inbox_statement)).all()
status_bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label(
"status_bucket"
)
progress_case = case((col(Task.status) == "in_progress", 1), else_=0) progress_case = case((col(Task.status) == "in_progress", 1), else_=0)
review_case = case((col(Task.status) == "review", 1), else_=0) review_case = case((col(Task.status) == "review", 1), else_=0)
statement = ( done_case = case((col(Task.status) == "done", 1), else_=0)
status_statement = (
select( select(
bucket_col, status_bucket_col,
func.sum(inbox_case),
func.sum(progress_case), func.sum(progress_case),
func.sum(review_case), func.sum(review_case),
func.sum(done_case),
) )
.where(col(Task.updated_at) >= range_spec.start) .where(col(Task.updated_at) >= range_spec.start)
.where(col(Task.updated_at) <= range_spec.end) .where(col(Task.updated_at) <= range_spec.end)
.where(col(Task.board_id).in_(board_ids))
.group_by(status_bucket_col)
.order_by(status_bucket_col)
) )
if not board_ids: status_results = (await session.exec(status_statement)).all()
return _wip_series_from_mapping(range_spec, {})
statement = (
statement.where(col(Task.board_id).in_(board_ids)).group_by(bucket_col).order_by(bucket_col)
)
results = (await session.exec(statement)).all()
mapping: dict[datetime, dict[str, int]] = {} mapping: dict[datetime, dict[str, int]] = {}
for bucket, inbox, in_progress, review in results: for bucket, inbox in inbox_results:
mapping[bucket] = { values = mapping.setdefault(bucket, {})
"inbox": int(inbox or 0), values["inbox"] = int(inbox or 0)
"in_progress": int(in_progress or 0), for bucket, in_progress, review, done in status_results:
"review": int(review or 0), values = mapping.setdefault(bucket, {})
} values["in_progress"] = int(in_progress or 0)
values["review"] = int(review or 0)
values["done"] = int(done or 0)
return _wip_series_from_mapping(range_spec, mapping) return _wip_series_from_mapping(range_spec, mapping)
async def _median_cycle_time_7d( async def _median_cycle_time_for_range(
session: AsyncSession, session: AsyncSession,
range_spec: RangeSpec,
board_ids: list[UUID], board_ids: list[UUID],
) -> float | None: ) -> float | None:
now = utcnow()
start = now - timedelta(days=7)
in_progress = sql_cast(Task.in_progress_at, DateTime) in_progress = sql_cast(Task.in_progress_at, DateTime)
duration_hours = func.extract("epoch", Task.updated_at - in_progress) / 3600.0 duration_hours = func.extract("epoch", Task.updated_at - in_progress) / 3600.0
statement = ( statement = (
select(func.percentile_cont(0.5).within_group(duration_hours)) select(func.percentile_cont(0.5).within_group(duration_hours))
.where(col(Task.status) == "review") .where(col(Task.status) == "review")
.where(col(Task.in_progress_at).is_not(None)) .where(col(Task.in_progress_at).is_not(None))
.where(col(Task.updated_at) >= start) .where(col(Task.updated_at) >= range_spec.start)
.where(col(Task.updated_at) <= now) .where(col(Task.updated_at) <= range_spec.end)
) )
if not board_ids: if not board_ids:
return None return None
@@ -303,11 +354,15 @@ async def _error_rate_kpi(
return (error_count / total_count) * 100 if total_count > 0 else 0.0 return (error_count / total_count) * 100 if total_count > 0 else 0.0
async def _active_agents(session: AsyncSession, board_ids: list[UUID]) -> int: async def _active_agents(
threshold = utcnow() - OFFLINE_AFTER session: AsyncSession,
range_spec: RangeSpec,
board_ids: list[UUID],
) -> int:
statement = select(func.count()).where( statement = select(func.count()).where(
col(Agent.last_seen_at).is_not(None), col(Agent.last_seen_at).is_not(None),
col(Agent.last_seen_at) >= threshold, col(Agent.last_seen_at) >= range_spec.start,
col(Agent.last_seen_at) <= range_spec.end,
) )
if not board_ids: if not board_ids:
return 0 return 0
@@ -316,12 +371,18 @@ async def _active_agents(session: AsyncSession, board_ids: list[UUID]) -> int:
return int(result) return int(result)
async def _tasks_in_progress(session: AsyncSession, board_ids: list[UUID]) -> int: async def _tasks_in_progress(
session: AsyncSession,
range_spec: RangeSpec,
board_ids: list[UUID],
) -> int:
if not board_ids: if not board_ids:
return 0 return 0
statement = ( statement = (
select(func.count()) select(func.count())
.where(col(Task.status) == "in_progress") .where(col(Task.status) == "in_progress")
.where(col(Task.updated_at) >= range_spec.start)
.where(col(Task.updated_at) <= range_spec.end)
.where(col(Task.board_id).in_(board_ids)) .where(col(Task.board_id).in_(board_ids))
) )
result = (await session.exec(statement)).one() result = (await session.exec(statement)).one()
@@ -330,13 +391,13 @@ async def _tasks_in_progress(session: AsyncSession, board_ids: list[UUID]) -> in
@router.get("/dashboard", response_model=DashboardMetrics) @router.get("/dashboard", response_model=DashboardMetrics)
async def dashboard_metrics( async def dashboard_metrics(
range_key: Literal["24h", "7d"] = RANGE_QUERY, range_key: DashboardRangeKey = RANGE_QUERY,
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> DashboardMetrics: ) -> DashboardMetrics:
"""Return dashboard KPIs and time-series data for accessible boards.""" """Return dashboard KPIs and time-series data for accessible boards."""
primary = _resolve_range(range_key) primary = _resolve_range(range_key)
comparison = _comparison_range(range_key) comparison = _comparison_range(primary)
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False) board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
throughput_primary = await _query_throughput(session, primary, board_ids) throughput_primary = await _query_throughput(session, primary, board_ids)
@@ -365,10 +426,14 @@ async def dashboard_metrics(
) )
kpis = DashboardKpis( kpis = DashboardKpis(
active_agents=await _active_agents(session, board_ids), active_agents=await _active_agents(session, primary, board_ids),
tasks_in_progress=await _tasks_in_progress(session, board_ids), tasks_in_progress=await _tasks_in_progress(session, primary, board_ids),
error_rate_pct=await _error_rate_kpi(session, primary, board_ids), error_rate_pct=await _error_rate_kpi(session, primary, board_ids),
median_cycle_time_hours_7d=await _median_cycle_time_7d(session, board_ids), median_cycle_time_hours_7d=await _median_cycle_time_for_range(
session,
primary,
board_ids,
),
) )
return DashboardMetrics( return DashboardMetrics(

View File

@@ -8,6 +8,8 @@ from typing import Literal
from sqlmodel import SQLModel from sqlmodel import SQLModel
RUNTIME_ANNOTATION_TYPES = (datetime,) RUNTIME_ANNOTATION_TYPES = (datetime,)
DashboardRangeKey = Literal["24h", "3d", "7d", "14d", "1m", "3m", "6m", "1y"]
DashboardBucketKey = Literal["hour", "day", "week", "month"]
class DashboardSeriesPoint(SQLModel): class DashboardSeriesPoint(SQLModel):
@@ -24,21 +26,22 @@ class DashboardWipPoint(SQLModel):
inbox: int inbox: int
in_progress: int in_progress: int
review: int review: int
done: int
class DashboardRangeSeries(SQLModel): class DashboardRangeSeries(SQLModel):
"""Series payload for a single range/bucket combination.""" """Series payload for a single range/bucket combination."""
range: Literal["24h", "7d"] range: DashboardRangeKey
bucket: Literal["hour", "day"] bucket: DashboardBucketKey
points: list[DashboardSeriesPoint] points: list[DashboardSeriesPoint]
class DashboardWipRangeSeries(SQLModel): class DashboardWipRangeSeries(SQLModel):
"""WIP series payload for a single range/bucket combination.""" """WIP series payload for a single range/bucket combination."""
range: Literal["24h", "7d"] range: DashboardRangeKey
bucket: Literal["hour", "day"] bucket: DashboardBucketKey
points: list[DashboardWipPoint] points: list[DashboardWipPoint]
@@ -68,7 +71,7 @@ class DashboardKpis(SQLModel):
class DashboardMetrics(SQLModel): class DashboardMetrics(SQLModel):
"""Complete dashboard metrics response payload.""" """Complete dashboard metrics response payload."""
range: Literal["24h", "7d"] range: DashboardRangeKey
generated_at: datetime generated_at: datetime
kpis: DashboardKpis kpis: DashboardKpis
throughput: DashboardSeriesSet throughput: DashboardSeriesSet

View File

@@ -0,0 +1,87 @@
from __future__ import annotations
from datetime import datetime, timedelta
import pytest
from app.api import metrics as metrics_api
from app.schemas.metrics import DashboardRangeKey
@pytest.mark.parametrize(
("range_key", "expected_bucket", "expected_duration"),
[
("24h", "hour", timedelta(hours=24)),
("3d", "day", timedelta(days=3)),
("7d", "day", timedelta(days=7)),
("14d", "day", timedelta(days=14)),
("1m", "day", timedelta(days=30)),
("3m", "week", timedelta(days=90)),
("6m", "week", timedelta(days=180)),
("1y", "month", timedelta(days=365)),
],
)
def test_resolve_range_maps_expected_window(
monkeypatch: pytest.MonkeyPatch,
range_key: DashboardRangeKey,
expected_bucket: str,
expected_duration: timedelta,
) -> None:
fixed_now = datetime(2026, 2, 12, 15, 30, 0)
monkeypatch.setattr(metrics_api, "utcnow", lambda: fixed_now)
spec = metrics_api._resolve_range(range_key)
assert spec.key == range_key
assert spec.bucket == expected_bucket
assert spec.duration == expected_duration
assert spec.start == fixed_now - expected_duration
assert spec.end == fixed_now
def test_comparison_range_is_previous_window(monkeypatch: pytest.MonkeyPatch) -> None:
fixed_now = datetime(2026, 2, 12, 15, 30, 0)
monkeypatch.setattr(metrics_api, "utcnow", lambda: fixed_now)
primary = metrics_api._resolve_range("14d")
comparison = metrics_api._comparison_range(primary)
assert comparison.key == primary.key
assert comparison.bucket == primary.bucket
assert comparison.duration == primary.duration
assert comparison.start == primary.start - primary.duration
assert comparison.end == primary.end - primary.duration
def test_week_buckets_align_to_monday(monkeypatch: pytest.MonkeyPatch) -> None:
fixed_now = datetime(2026, 2, 12, 15, 30, 0)
monkeypatch.setattr(metrics_api, "utcnow", lambda: fixed_now)
spec = metrics_api._resolve_range("3m")
buckets = metrics_api._build_buckets(spec)
assert buckets
assert all(bucket.weekday() == 0 for bucket in buckets)
assert all(
buckets[index + 1] - buckets[index] == timedelta(days=7)
for index in range(len(buckets) - 1)
)
def test_month_buckets_align_to_first_of_month(monkeypatch: pytest.MonkeyPatch) -> None:
fixed_now = datetime(2026, 2, 12, 15, 30, 0)
monkeypatch.setattr(metrics_api, "utcnow", lambda: fixed_now)
spec = metrics_api._resolve_range("1y")
buckets = metrics_api._build_buckets(spec)
assert buckets
assert all(
bucket.day == 1
and bucket.hour == 0
and bucket.minute == 0
and bucket.second == 0
and bucket.microsecond == 0
for bucket in buckets
)
assert len(buckets) >= 12

View File

@@ -10,5 +10,11 @@ export type DashboardMetricsApiV1MetricsDashboardGetRangeKey =
export const DashboardMetricsApiV1MetricsDashboardGetRangeKey = { export const DashboardMetricsApiV1MetricsDashboardGetRangeKey = {
"24h": "24h", "24h": "24h",
"3d": "3d",
"7d": "7d", "7d": "7d",
"14d": "14d",
"1m": "1m",
"3m": "3m",
"6m": "6m",
"1y": "1y",
} as const; } as const;

View File

@@ -10,5 +10,11 @@ export type DashboardMetricsRange =
export const DashboardMetricsRange = { export const DashboardMetricsRange = {
"24h": "24h", "24h": "24h",
"3d": "3d",
"7d": "7d", "7d": "7d",
"14d": "14d",
"1m": "1m",
"3m": "3m",
"6m": "6m",
"1y": "1y",
} as const; } as const;

View File

@@ -11,4 +11,6 @@ export type DashboardRangeSeriesBucket =
export const DashboardRangeSeriesBucket = { export const DashboardRangeSeriesBucket = {
hour: "hour", hour: "hour",
day: "day", day: "day",
week: "week",
month: "month",
} as const; } as const;

View File

@@ -10,5 +10,11 @@ export type DashboardRangeSeriesRange =
export const DashboardRangeSeriesRange = { export const DashboardRangeSeriesRange = {
"24h": "24h", "24h": "24h",
"3d": "3d",
"7d": "7d", "7d": "7d",
"14d": "14d",
"1m": "1m",
"3m": "3m",
"6m": "6m",
"1y": "1y",
} as const; } as const;

View File

@@ -13,4 +13,5 @@ export interface DashboardWipPoint {
inbox: number; inbox: number;
in_progress: number; in_progress: number;
review: number; review: number;
done: number;
} }

View File

@@ -11,4 +11,6 @@ export type DashboardWipRangeSeriesBucket =
export const DashboardWipRangeSeriesBucket = { export const DashboardWipRangeSeriesBucket = {
hour: "hour", hour: "hour",
day: "day", day: "day",
week: "week",
month: "month",
} as const; } as const;

View File

@@ -10,5 +10,11 @@ export type DashboardWipRangeSeriesRange =
export const DashboardWipRangeSeriesRange = { export const DashboardWipRangeSeriesRange = {
"24h": "24h", "24h": "24h",
"3d": "3d",
"7d": "7d", "7d": "7d",
"14d": "14d",
"1m": "1m",
"3m": "3m",
"6m": "6m",
"1y": "1y",
} as const; } as const;

View File

@@ -3,6 +3,7 @@
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
import { useMemo } from "react"; import { useMemo } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { SignedIn, SignedOut, useAuth } from "@/auth/clerk"; import { SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import { import {
@@ -13,26 +14,28 @@ import {
CartesianGrid, CartesianGrid,
Line, Line,
LineChart, LineChart,
Legend,
ResponsiveContainer, ResponsiveContainer,
Tooltip, Tooltip,
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { Activity, Clock, PenSquare, Timer, Users } from "lucide-react"; import { Activity, PenSquare, Timer, Users } from "lucide-react";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell"; import { DashboardShell } from "@/components/templates/DashboardShell";
import MetricSparkline from "@/components/charts/metric-sparkline"; import DropdownSelect from "@/components/ui/dropdown-select";
import { SignedOutPanel } from "@/components/auth/SignedOutPanel"; import { SignedOutPanel } from "@/components/auth/SignedOutPanel";
import { ApiError } from "@/api/mutator"; import { ApiError } from "@/api/mutator";
import { import {
type dashboardMetricsApiV1MetricsDashboardGetResponse, type dashboardMetricsApiV1MetricsDashboardGetResponse,
useDashboardMetricsApiV1MetricsDashboardGet, useDashboardMetricsApiV1MetricsDashboardGet,
} from "@/api/generated/metrics/metrics"; } from "@/api/generated/metrics/metrics";
import type { DashboardMetricsApiV1MetricsDashboardGetRangeKey } from "@/api/generated/model/dashboardMetricsApiV1MetricsDashboardGetRangeKey";
import { parseApiDatetime } from "@/lib/datetime"; import { parseApiDatetime } from "@/lib/datetime";
type RangeKey = "24h" | "7d"; type RangeKey = DashboardMetricsApiV1MetricsDashboardGetRangeKey;
type BucketKey = "hour" | "day"; type BucketKey = "hour" | "day" | "week" | "month";
type SeriesPoint = { type SeriesPoint = {
period: string; period: string;
@@ -44,6 +47,7 @@ type WipPoint = {
inbox: number; inbox: number;
in_progress: number; in_progress: number;
review: number; review: number;
done: number;
}; };
type RangeSeries = { type RangeSeries = {
@@ -63,17 +67,32 @@ const dayFormatter = new Intl.DateTimeFormat("en-US", {
month: "short", month: "short",
day: "numeric", day: "numeric",
}); });
const updatedFormatter = new Intl.DateTimeFormat("en-US", { const monthFormatter = new Intl.DateTimeFormat("en-US", {
hour: "numeric", month: "short",
minute: "2-digit", year: "numeric",
}); });
const DASHBOARD_RANGE_OPTIONS: Array<{ value: RangeKey; label: string }> = [
{ value: "24h", label: "24 hours" },
{ value: "3d", label: "3 days" },
{ value: "7d", label: "7 days" },
{ value: "14d", label: "14 days" },
{ value: "1m", label: "1 month" },
{ value: "3m", label: "3 months" },
{ value: "6m", label: "6 months" },
{ value: "1y", label: "1 year" },
];
const DASHBOARD_RANGE_SET = new Set<RangeKey>(
DASHBOARD_RANGE_OPTIONS.map((option) => option.value),
);
const DEFAULT_RANGE: RangeKey = "7d";
const formatPeriod = (value: string, bucket: BucketKey) => { const formatPeriod = (value: string, bucket: BucketKey) => {
const date = parseApiDatetime(value); const date = parseApiDatetime(value);
if (!date) return ""; if (!date) return "";
return bucket === "hour" if (bucket === "hour") return hourFormatter.format(date);
? hourFormatter.format(date) if (bucket === "month") return monthFormatter.format(date);
: dayFormatter.format(date); return dayFormatter.format(date);
}; };
const formatNumber = (value: number) => value.toLocaleString("en-US"); const formatNumber = (value: number) => value.toLocaleString("en-US");
@@ -101,6 +120,7 @@ function buildWipSeries(series: WipRangeSeries) {
inbox: Number(point.inbox ?? 0), inbox: Number(point.inbox ?? 0),
in_progress: Number(point.in_progress ?? 0), in_progress: Number(point.in_progress ?? 0),
review: Number(point.review ?? 0), review: Number(point.review ?? 0),
done: Number(point.done ?? 0),
})); }));
} }
@@ -110,6 +130,7 @@ function buildSparkline(series: RangeSeries) {
labels: series.points.map((point) => labels: series.points.map((point) =>
formatPeriod(point.period, series.bucket), formatPeriod(point.period, series.bucket),
), ),
bucket: series.bucket,
}; };
} }
@@ -119,6 +140,7 @@ function buildWipSparkline(series: WipRangeSeries, key: keyof WipPoint) {
labels: series.points.map((point) => labels: series.points.map((point) =>
formatPeriod(point.period, series.bucket), formatPeriod(point.period, series.bucket),
), ),
bucket: series.bucket,
}; };
} }
@@ -133,11 +155,11 @@ function TooltipCard({ active, payload, label, formatter }: TooltipProps) {
if (!active || !payload?.length) return null; if (!active || !payload?.length) return null;
return ( return (
<div className="rounded-lg bg-slate-900/95 px-3 py-2 text-xs text-slate-200 shadow-lg"> <div className="rounded-lg bg-slate-900/95 px-3 py-2 text-xs text-slate-200 shadow-lg">
<div className="text-slate-400">{label}</div> {label ? <div className="text-slate-400">Period: {label}</div> : null}
<div className="mt-1 space-y-1"> <div className="mt-1 space-y-1">
{payload.map((entry) => ( {payload.map((entry, index) => (
<div <div
key={entry.name} key={`${entry.name ?? "value"}-${index}`}
className="flex items-center justify-between gap-3" className="flex items-center justify-between gap-3"
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@@ -145,9 +167,10 @@ function TooltipCard({ active, payload, label, formatter }: TooltipProps) {
className="h-2 w-2 rounded-full" className="h-2 w-2 rounded-full"
style={{ backgroundColor: entry.color }} style={{ backgroundColor: entry.color }}
/> />
{entry.name} {entry.name ?? "Value"}
</span> </span>
<span className="font-semibold text-slate-900"> <span className="font-semibold text-slate-100">
<span className="text-slate-400">Value: </span>
{formatter {formatter
? formatter(Number(entry.value ?? 0), entry.name) ? formatter(Number(entry.value ?? 0), entry.name)
: entry.value} : entry.value}
@@ -202,12 +225,10 @@ function ChartCard({
title, title,
subtitle, subtitle,
children, children,
sparkline,
}: { }: {
title: string; title: string;
subtitle: string; subtitle: string;
children: React.ReactNode; children: React.ReactNode;
sparkline?: { values: number[]; labels: string[] };
}) { }) {
return ( return (
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
@@ -218,36 +239,27 @@ function ChartCard({
</h3> </h3>
<p className="mt-1 text-sm text-slate-500">{subtitle}</p> <p className="mt-1 text-sm text-slate-500">{subtitle}</p>
</div> </div>
<span className="rounded-full bg-slate-100 px-3 py-1.5 text-xs font-medium text-slate-500">
24h
</span>
</div> </div>
<div className="h-56">{children}</div> <div className="h-56">{children}</div>
{sparkline ? (
<div className="mt-4 border-t border-slate-100 pt-4">
<div className="flex items-center gap-2 text-xs text-slate-500">
<span className="h-2 w-2 rounded-full bg-blue-500" />
7d trend
</div>
<MetricSparkline
values={sparkline.values}
labels={sparkline.labels}
bucket="week"
className="mt-2"
/>
</div>
) : null}
</div> </div>
); );
} }
export default function DashboardPage() { export default function DashboardPage() {
const { isSignedIn } = useAuth(); const { isSignedIn } = useAuth();
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const selectedRangeParam = searchParams.get("range");
const selectedRange: RangeKey =
selectedRangeParam && DASHBOARD_RANGE_SET.has(selectedRangeParam as RangeKey)
? (selectedRangeParam as RangeKey)
: DEFAULT_RANGE;
const metricsQuery = useDashboardMetricsApiV1MetricsDashboardGet< const metricsQuery = useDashboardMetricsApiV1MetricsDashboardGet<
dashboardMetricsApiV1MetricsDashboardGetResponse, dashboardMetricsApiV1MetricsDashboardGetResponse,
ApiError ApiError
>( >(
{ range_key: "24h" }, { range_key: selectedRange },
{ {
query: { query: {
enabled: Boolean(isSignedIn), enabled: Boolean(isSignedIn),
@@ -277,21 +289,17 @@ export default function DashboardPage() {
[metrics], [metrics],
); );
const throughputSpark = useMemo(
() => (metrics ? buildSparkline(metrics.throughput.comparison) : null),
[metrics],
);
const cycleSpark = useMemo( const cycleSpark = useMemo(
() => (metrics ? buildSparkline(metrics.cycle_time.comparison) : null), () => (metrics ? buildSparkline(metrics.cycle_time.primary) : null),
[metrics], [metrics],
); );
const errorSpark = useMemo( const errorSpark = useMemo(
() => (metrics ? buildSparkline(metrics.error_rate.comparison) : null), () => (metrics ? buildSparkline(metrics.error_rate.primary) : null),
[metrics], [metrics],
); );
const wipSpark = useMemo( const wipSpark = useMemo(
() => () =>
metrics ? buildWipSparkline(metrics.wip.comparison, "in_progress") : null, metrics ? buildWipSparkline(metrics.wip.primary, "in_progress") : null,
[metrics], [metrics],
); );
@@ -309,13 +317,6 @@ export default function DashboardPage() {
[cycleSpark], [cycleSpark],
); );
const updatedAtLabel = useMemo(() => {
if (!metrics?.generated_at) return null;
const date = parseApiDatetime(metrics.generated_at);
if (!date) return null;
return updatedFormatter.format(date);
}, [metrics]);
return ( return (
<DashboardShell> <DashboardShell>
<SignedOut> <SignedOut>
@@ -329,7 +330,7 @@ export default function DashboardPage() {
<DashboardSidebar /> <DashboardSidebar />
<main className="flex-1 overflow-y-auto bg-slate-50"> <main className="flex-1 overflow-y-auto bg-slate-50">
<div className="border-b border-slate-200 bg-white px-8 py-6"> <div className="border-b border-slate-200 bg-white px-8 py-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-4">
<div> <div>
<h2 className="font-heading text-2xl font-semibold text-slate-900 tracking-tight"> <h2 className="font-heading text-2xl font-semibold text-slate-900 tracking-tight">
Dashboard Dashboard
@@ -338,12 +339,23 @@ export default function DashboardPage() {
Monitor your mission control operations Monitor your mission control operations
</p> </p>
</div> </div>
{updatedAtLabel ? ( <div className="flex flex-wrap items-center justify-end gap-3">
<div className="flex items-center gap-2 text-sm text-slate-500"> <DropdownSelect
<Clock className="h-4 w-4" /> value={selectedRange}
Updated {updatedAtLabel} onValueChange={(value) => {
</div> const nextRange = value as RangeKey;
) : null} const params = new URLSearchParams(searchParams.toString());
params.set("range", nextRange);
router.replace(`${pathname}?${params.toString()}`);
}}
options={DASHBOARD_RANGE_OPTIONS}
ariaLabel="Dashboard date range"
placeholder="Select range"
searchEnabled={false}
triggerClassName="h-9 min-w-[150px] rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
contentClassName="rounded-lg border border-slate-200"
/>
</div>
</div> </div>
</div> </div>
<div className="p-8"> <div className="p-8">
@@ -365,28 +377,24 @@ export default function DashboardPage() {
<KpiCard <KpiCard
label="Active agents" label="Active agents"
value={formatNumber(metrics.kpis.active_agents)} value={formatNumber(metrics.kpis.active_agents)}
sublabel="Last 10 minutes"
icon={<Users className="h-4 w-4" />} icon={<Users className="h-4 w-4" />}
progress={activeProgress} progress={activeProgress}
/> />
<KpiCard <KpiCard
label="Tasks in progress" label="Tasks in progress"
value={formatNumber(metrics.kpis.tasks_in_progress)} value={formatNumber(metrics.kpis.tasks_in_progress)}
sublabel="Current WIP"
icon={<PenSquare className="h-4 w-4" />} icon={<PenSquare className="h-4 w-4" />}
progress={wipProgress} progress={wipProgress}
/> />
<KpiCard <KpiCard
label="Error rate" label="Error rate"
value={formatPercent(metrics.kpis.error_rate_pct)} value={formatPercent(metrics.kpis.error_rate_pct)}
sublabel="24h average"
icon={<Activity className="h-4 w-4" />} icon={<Activity className="h-4 w-4" />}
progress={errorProgress} progress={errorProgress}
/> />
<KpiCard <KpiCard
label="Median cycle time" label="Median cycle time"
value={formatHours(metrics.kpis.median_cycle_time_hours_7d)} value={formatHours(metrics.kpis.median_cycle_time_hours_7d)}
sublabel="7d median"
icon={<Timer className="h-4 w-4" />} icon={<Timer className="h-4 w-4" />}
progress={cycleProgress} progress={cycleProgress}
/> />
@@ -396,7 +404,6 @@ export default function DashboardPage() {
<ChartCard <ChartCard
title="Completed Tasks" title="Completed Tasks"
subtitle="Throughput" subtitle="Throughput"
sparkline={throughputSpark ?? undefined}
> >
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart <BarChart
@@ -421,6 +428,17 @@ export default function DashboardPage() {
<TooltipCard formatter={(v) => formatNumber(v)} /> <TooltipCard formatter={(v) => formatNumber(v)} />
} }
/> />
<Legend
verticalAlign="bottom"
align="center"
iconType="circle"
iconSize={8}
wrapperStyle={{
paddingTop: "8px",
fontSize: "12px",
color: "#64748b",
}}
/>
<Bar <Bar
dataKey="value" dataKey="value"
name="Completed" name="Completed"
@@ -434,7 +452,6 @@ export default function DashboardPage() {
<ChartCard <ChartCard
title="Avg Hours to Review" title="Avg Hours to Review"
subtitle="Cycle time" subtitle="Cycle time"
sparkline={cycleSpark ?? undefined}
> >
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart <LineChart
@@ -461,6 +478,17 @@ export default function DashboardPage() {
/> />
} }
/> />
<Legend
verticalAlign="bottom"
align="center"
iconType="circle"
iconSize={8}
wrapperStyle={{
paddingTop: "8px",
fontSize: "12px",
color: "#64748b",
}}
/>
<Line <Line
type="monotone" type="monotone"
dataKey="value" dataKey="value"
@@ -476,7 +504,6 @@ export default function DashboardPage() {
<ChartCard <ChartCard
title="Failed Events" title="Failed Events"
subtitle="Error rate" subtitle="Error rate"
sparkline={errorSpark ?? undefined}
> >
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart <LineChart
@@ -501,6 +528,17 @@ export default function DashboardPage() {
<TooltipCard formatter={(v) => formatPercent(v)} /> <TooltipCard formatter={(v) => formatPercent(v)} />
} }
/> />
<Legend
verticalAlign="bottom"
align="center"
iconType="circle"
iconSize={8}
wrapperStyle={{
paddingTop: "8px",
fontSize: "12px",
color: "#64748b",
}}
/>
<Line <Line
type="monotone" type="monotone"
dataKey="value" dataKey="value"
@@ -516,7 +554,6 @@ export default function DashboardPage() {
<ChartCard <ChartCard
title="Status Distribution" title="Status Distribution"
subtitle="Work in progress" subtitle="Work in progress"
sparkline={wipSpark ?? undefined}
> >
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<AreaChart <AreaChart
@@ -541,13 +578,24 @@ export default function DashboardPage() {
<TooltipCard formatter={(v) => formatNumber(v)} /> <TooltipCard formatter={(v) => formatNumber(v)} />
} }
/> />
<Legend
verticalAlign="bottom"
align="center"
iconType="circle"
iconSize={8}
wrapperStyle={{
paddingTop: "8px",
fontSize: "12px",
color: "#64748b",
}}
/>
<Area <Area
type="monotone" type="monotone"
dataKey="inbox" dataKey="inbox"
name="Inbox" name="Inbox"
stackId="wip" stackId="wip"
fill="#dbeafe" fill="#fed7aa"
stroke="#93c5fd" stroke="#ea580c"
fillOpacity={0.8} fillOpacity={0.8}
/> />
<Area <Area
@@ -555,8 +603,8 @@ export default function DashboardPage() {
dataKey="in_progress" dataKey="in_progress"
name="In progress" name="In progress"
stackId="wip" stackId="wip"
fill="#93c5fd" fill="#bfdbfe"
stroke="#2563eb" stroke="#1d4ed8"
fillOpacity={0.8} fillOpacity={0.8}
/> />
<Area <Area
@@ -564,10 +612,19 @@ export default function DashboardPage() {
dataKey="review" dataKey="review"
name="Review" name="Review"
stackId="wip" stackId="wip"
fill="#60a5fa" fill="#e9d5ff"
stroke="#1d4ed8" stroke="#7e22ce"
fillOpacity={0.85} fillOpacity={0.85}
/> />
<Area
type="monotone"
dataKey="done"
name="Done"
stackId="wip"
fill="#bbf7d0"
stroke="#15803d"
fillOpacity={0.9}
/>
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</ChartCard> </ChartCard>