From 8bd606a8dc9d476b6adfd8461f0c094145293f65 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 12 Feb 2026 19:29:58 +0530 Subject: [PATCH] feat(metrics): extend time range options and update related metrics handling --- backend/app/api/metrics.py | 169 ++++++++++----- backend/app/schemas/metrics.py | 13 +- backend/tests/test_metrics_ranges.py | 87 ++++++++ ...MetricsApiV1MetricsDashboardGetRangeKey.ts | 6 + .../generated/model/dashboardMetricsRange.ts | 6 + .../model/dashboardRangeSeriesBucket.ts | 2 + .../model/dashboardRangeSeriesRange.ts | 6 + .../api/generated/model/dashboardWipPoint.ts | 1 + .../model/dashboardWipRangeSeriesBucket.ts | 2 + .../model/dashboardWipRangeSeriesRange.ts | 6 + frontend/src/app/dashboard/page.tsx | 197 +++++++++++------- 11 files changed, 368 insertions(+), 127 deletions(-) create mode 100644 backend/tests/test_metrics_ranges.py diff --git a/backend/app/api/metrics.py b/backend/app/api/metrics.py index edf2febf..32946ff0 100644 --- a/backend/app/api/metrics.py +++ b/backend/app/api/metrics.py @@ -4,7 +4,6 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Literal from uuid import UUID 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.tasks import Task from app.schemas.metrics import ( + DashboardBucketKey, DashboardKpis, DashboardMetrics, + DashboardRangeKey, DashboardRangeSeries, DashboardSeriesPoint, DashboardSeriesSet, @@ -34,7 +35,6 @@ from app.services.organizations import OrganizationContext, list_accessible_boar router = APIRouter(prefix="/metrics", tags=["metrics"]) -OFFLINE_AFTER = timedelta(minutes=10) ERROR_EVENT_PATTERN = "%failed" _RUNTIME_TYPE_REFERENCES = (UUID, AsyncSession) RANGE_QUERY = Query(default="24h") @@ -46,46 +46,77 @@ ORG_MEMBER_DEP = Depends(require_org_member) class RangeSpec: """Resolved time-range specification for metric aggregation.""" - key: Literal["24h", "7d"] + key: DashboardRangeKey start: 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() - if range_key == "7d": - return RangeSpec( - key="7d", - start=now - timedelta(days=7), - end=now, - bucket="day", - ) + specs: dict[DashboardRangeKey, tuple[timedelta, DashboardBucketKey]] = { + "24h": (timedelta(hours=24), "hour"), + "3d": (timedelta(days=3), "day"), + "7d": (timedelta(days=7), "day"), + "14d": (timedelta(days=14), "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( - key="24h", - start=now - timedelta(hours=24), + key=range_key, + start=now - duration, end=now, - bucket="hour", + bucket=bucket, + duration=duration, ) -def _comparison_range(range_key: Literal["24h", "7d"]) -> RangeSpec: - return _resolve_range("7d" if range_key == "24h" else "24h") +def _comparison_range(range_spec: RangeSpec) -> RangeSpec: + 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": - return value.replace(hour=0, minute=0, second=0, microsecond=0) + return normalized 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]: 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 + cursor = _next_bucket(cursor, range_spec.bucket) return buckets @@ -117,6 +148,7 @@ def _wip_series_from_mapping( inbox=values.get("inbox", 0), in_progress=values.get("in_progress", 0), review=values.get("review", 0), + done=values.get("done", 0), ), ) return DashboardWipRangeSeries( @@ -215,50 +247,69 @@ async def _query_wip( range_spec: RangeSpec, board_ids: list[UUID], ) -> DashboardWipRangeSeries: - bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket") - inbox_case = case((col(Task.status) == "inbox", 1), else_=0) + if not board_ids: + 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) review_case = case((col(Task.status) == "review", 1), else_=0) - statement = ( + done_case = case((col(Task.status) == "done", 1), else_=0) + status_statement = ( select( - bucket_col, - func.sum(inbox_case), + status_bucket_col, func.sum(progress_case), func.sum(review_case), + func.sum(done_case), ) .where(col(Task.updated_at) >= range_spec.start) .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: - 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() + status_results = (await session.exec(status_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), - } + for bucket, inbox in inbox_results: + values = mapping.setdefault(bucket, {}) + values["inbox"] = int(inbox or 0) + for bucket, in_progress, review, done in status_results: + 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) -async def _median_cycle_time_7d( +async def _median_cycle_time_for_range( session: AsyncSession, + range_spec: RangeSpec, board_ids: list[UUID], ) -> float | None: - now = utcnow() - start = now - timedelta(days=7) in_progress = sql_cast(Task.in_progress_at, DateTime) duration_hours = func.extract("epoch", Task.updated_at - in_progress) / 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) + .where(col(Task.updated_at) >= range_spec.start) + .where(col(Task.updated_at) <= range_spec.end) ) if not board_ids: return None @@ -303,11 +354,15 @@ async def _error_rate_kpi( return (error_count / total_count) * 100 if total_count > 0 else 0.0 -async def _active_agents(session: AsyncSession, board_ids: list[UUID]) -> int: - threshold = utcnow() - OFFLINE_AFTER +async def _active_agents( + session: AsyncSession, + range_spec: RangeSpec, + board_ids: list[UUID], +) -> int: statement = select(func.count()).where( 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: return 0 @@ -316,12 +371,18 @@ async def _active_agents(session: AsyncSession, board_ids: list[UUID]) -> int: 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: return 0 statement = ( select(func.count()) .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)) ) 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) async def dashboard_metrics( - range_key: Literal["24h", "7d"] = RANGE_QUERY, + range_key: DashboardRangeKey = RANGE_QUERY, session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> DashboardMetrics: """Return dashboard KPIs and time-series data for accessible boards.""" 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) throughput_primary = await _query_throughput(session, primary, board_ids) @@ -365,10 +426,14 @@ async def dashboard_metrics( ) kpis = DashboardKpis( - active_agents=await _active_agents(session, board_ids), - tasks_in_progress=await _tasks_in_progress(session, board_ids), + active_agents=await _active_agents(session, primary, board_ids), + tasks_in_progress=await _tasks_in_progress(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( diff --git a/backend/app/schemas/metrics.py b/backend/app/schemas/metrics.py index bb476f58..6a21ce48 100644 --- a/backend/app/schemas/metrics.py +++ b/backend/app/schemas/metrics.py @@ -8,6 +8,8 @@ from typing import Literal from sqlmodel import SQLModel RUNTIME_ANNOTATION_TYPES = (datetime,) +DashboardRangeKey = Literal["24h", "3d", "7d", "14d", "1m", "3m", "6m", "1y"] +DashboardBucketKey = Literal["hour", "day", "week", "month"] class DashboardSeriesPoint(SQLModel): @@ -24,21 +26,22 @@ class DashboardWipPoint(SQLModel): inbox: int in_progress: int review: int + done: int class DashboardRangeSeries(SQLModel): """Series payload for a single range/bucket combination.""" - range: Literal["24h", "7d"] - bucket: Literal["hour", "day"] + range: DashboardRangeKey + bucket: DashboardBucketKey points: list[DashboardSeriesPoint] class DashboardWipRangeSeries(SQLModel): """WIP series payload for a single range/bucket combination.""" - range: Literal["24h", "7d"] - bucket: Literal["hour", "day"] + range: DashboardRangeKey + bucket: DashboardBucketKey points: list[DashboardWipPoint] @@ -68,7 +71,7 @@ class DashboardKpis(SQLModel): class DashboardMetrics(SQLModel): """Complete dashboard metrics response payload.""" - range: Literal["24h", "7d"] + range: DashboardRangeKey generated_at: datetime kpis: DashboardKpis throughput: DashboardSeriesSet diff --git a/backend/tests/test_metrics_ranges.py b/backend/tests/test_metrics_ranges.py new file mode 100644 index 00000000..320a0f47 --- /dev/null +++ b/backend/tests/test_metrics_ranges.py @@ -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 diff --git a/frontend/src/api/generated/model/dashboardMetricsApiV1MetricsDashboardGetRangeKey.ts b/frontend/src/api/generated/model/dashboardMetricsApiV1MetricsDashboardGetRangeKey.ts index f140d43c..5003f9e9 100644 --- a/frontend/src/api/generated/model/dashboardMetricsApiV1MetricsDashboardGetRangeKey.ts +++ b/frontend/src/api/generated/model/dashboardMetricsApiV1MetricsDashboardGetRangeKey.ts @@ -10,5 +10,11 @@ export type DashboardMetricsApiV1MetricsDashboardGetRangeKey = export const DashboardMetricsApiV1MetricsDashboardGetRangeKey = { "24h": "24h", + "3d": "3d", "7d": "7d", + "14d": "14d", + "1m": "1m", + "3m": "3m", + "6m": "6m", + "1y": "1y", } as const; diff --git a/frontend/src/api/generated/model/dashboardMetricsRange.ts b/frontend/src/api/generated/model/dashboardMetricsRange.ts index 4598ae36..0b7e523b 100644 --- a/frontend/src/api/generated/model/dashboardMetricsRange.ts +++ b/frontend/src/api/generated/model/dashboardMetricsRange.ts @@ -10,5 +10,11 @@ export type DashboardMetricsRange = export const DashboardMetricsRange = { "24h": "24h", + "3d": "3d", "7d": "7d", + "14d": "14d", + "1m": "1m", + "3m": "3m", + "6m": "6m", + "1y": "1y", } as const; diff --git a/frontend/src/api/generated/model/dashboardRangeSeriesBucket.ts b/frontend/src/api/generated/model/dashboardRangeSeriesBucket.ts index ddde0ff9..386ecaa2 100644 --- a/frontend/src/api/generated/model/dashboardRangeSeriesBucket.ts +++ b/frontend/src/api/generated/model/dashboardRangeSeriesBucket.ts @@ -11,4 +11,6 @@ export type DashboardRangeSeriesBucket = export const DashboardRangeSeriesBucket = { hour: "hour", day: "day", + week: "week", + month: "month", } as const; diff --git a/frontend/src/api/generated/model/dashboardRangeSeriesRange.ts b/frontend/src/api/generated/model/dashboardRangeSeriesRange.ts index 99e5d3db..157dfdb4 100644 --- a/frontend/src/api/generated/model/dashboardRangeSeriesRange.ts +++ b/frontend/src/api/generated/model/dashboardRangeSeriesRange.ts @@ -10,5 +10,11 @@ export type DashboardRangeSeriesRange = export const DashboardRangeSeriesRange = { "24h": "24h", + "3d": "3d", "7d": "7d", + "14d": "14d", + "1m": "1m", + "3m": "3m", + "6m": "6m", + "1y": "1y", } as const; diff --git a/frontend/src/api/generated/model/dashboardWipPoint.ts b/frontend/src/api/generated/model/dashboardWipPoint.ts index ee76aee8..b36c941b 100644 --- a/frontend/src/api/generated/model/dashboardWipPoint.ts +++ b/frontend/src/api/generated/model/dashboardWipPoint.ts @@ -13,4 +13,5 @@ export interface DashboardWipPoint { inbox: number; in_progress: number; review: number; + done: number; } diff --git a/frontend/src/api/generated/model/dashboardWipRangeSeriesBucket.ts b/frontend/src/api/generated/model/dashboardWipRangeSeriesBucket.ts index b625ea22..85662295 100644 --- a/frontend/src/api/generated/model/dashboardWipRangeSeriesBucket.ts +++ b/frontend/src/api/generated/model/dashboardWipRangeSeriesBucket.ts @@ -11,4 +11,6 @@ export type DashboardWipRangeSeriesBucket = export const DashboardWipRangeSeriesBucket = { hour: "hour", day: "day", + week: "week", + month: "month", } as const; diff --git a/frontend/src/api/generated/model/dashboardWipRangeSeriesRange.ts b/frontend/src/api/generated/model/dashboardWipRangeSeriesRange.ts index 3f4715a6..09b4ad69 100644 --- a/frontend/src/api/generated/model/dashboardWipRangeSeriesRange.ts +++ b/frontend/src/api/generated/model/dashboardWipRangeSeriesRange.ts @@ -10,5 +10,11 @@ export type DashboardWipRangeSeriesRange = export const DashboardWipRangeSeriesRange = { "24h": "24h", + "3d": "3d", "7d": "7d", + "14d": "14d", + "1m": "1m", + "3m": "3m", + "6m": "6m", + "1y": "1y", } as const; diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index a2aaaf63..d564f309 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -3,6 +3,7 @@ export const dynamic = "force-dynamic"; import { useMemo } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { SignedIn, SignedOut, useAuth } from "@/auth/clerk"; import { @@ -13,26 +14,28 @@ import { CartesianGrid, Line, LineChart, + Legend, ResponsiveContainer, Tooltip, XAxis, YAxis, } 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 { 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 { ApiError } from "@/api/mutator"; import { type dashboardMetricsApiV1MetricsDashboardGetResponse, useDashboardMetricsApiV1MetricsDashboardGet, } from "@/api/generated/metrics/metrics"; +import type { DashboardMetricsApiV1MetricsDashboardGetRangeKey } from "@/api/generated/model/dashboardMetricsApiV1MetricsDashboardGetRangeKey"; import { parseApiDatetime } from "@/lib/datetime"; -type RangeKey = "24h" | "7d"; -type BucketKey = "hour" | "day"; +type RangeKey = DashboardMetricsApiV1MetricsDashboardGetRangeKey; +type BucketKey = "hour" | "day" | "week" | "month"; type SeriesPoint = { period: string; @@ -44,6 +47,7 @@ type WipPoint = { inbox: number; in_progress: number; review: number; + done: number; }; type RangeSeries = { @@ -63,17 +67,32 @@ const dayFormatter = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", }); -const updatedFormatter = new Intl.DateTimeFormat("en-US", { - hour: "numeric", - minute: "2-digit", +const monthFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + 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( + DASHBOARD_RANGE_OPTIONS.map((option) => option.value), +); +const DEFAULT_RANGE: RangeKey = "7d"; + const formatPeriod = (value: string, bucket: BucketKey) => { const date = parseApiDatetime(value); if (!date) return ""; - return bucket === "hour" - ? hourFormatter.format(date) - : dayFormatter.format(date); + if (bucket === "hour") return hourFormatter.format(date); + if (bucket === "month") return monthFormatter.format(date); + return dayFormatter.format(date); }; const formatNumber = (value: number) => value.toLocaleString("en-US"); @@ -101,6 +120,7 @@ function buildWipSeries(series: WipRangeSeries) { inbox: Number(point.inbox ?? 0), in_progress: Number(point.in_progress ?? 0), review: Number(point.review ?? 0), + done: Number(point.done ?? 0), })); } @@ -110,6 +130,7 @@ function buildSparkline(series: RangeSeries) { labels: series.points.map((point) => formatPeriod(point.period, series.bucket), ), + bucket: series.bucket, }; } @@ -119,6 +140,7 @@ function buildWipSparkline(series: WipRangeSeries, key: keyof WipPoint) { labels: series.points.map((point) => 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; return (
-
{label}
+ {label ?
Period: {label}
: null}
- {payload.map((entry) => ( + {payload.map((entry, index) => (
@@ -145,9 +167,10 @@ function TooltipCard({ active, payload, label, formatter }: TooltipProps) { className="h-2 w-2 rounded-full" style={{ backgroundColor: entry.color }} /> - {entry.name} + {entry.name ?? "Value"} - + + Value: {formatter ? formatter(Number(entry.value ?? 0), entry.name) : entry.value} @@ -202,12 +225,10 @@ function ChartCard({ title, subtitle, children, - sparkline, }: { title: string; subtitle: string; children: React.ReactNode; - sparkline?: { values: number[]; labels: string[] }; }) { return (
@@ -218,36 +239,27 @@ function ChartCard({

{subtitle}

- - 24h -
{children}
- {sparkline ? ( -
-
- - 7d trend -
- -
- ) : null}
); } export default function DashboardPage() { 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< dashboardMetricsApiV1MetricsDashboardGetResponse, ApiError >( - { range_key: "24h" }, + { range_key: selectedRange }, { query: { enabled: Boolean(isSignedIn), @@ -277,21 +289,17 @@ export default function DashboardPage() { [metrics], ); - const throughputSpark = useMemo( - () => (metrics ? buildSparkline(metrics.throughput.comparison) : null), - [metrics], - ); const cycleSpark = useMemo( - () => (metrics ? buildSparkline(metrics.cycle_time.comparison) : null), + () => (metrics ? buildSparkline(metrics.cycle_time.primary) : null), [metrics], ); const errorSpark = useMemo( - () => (metrics ? buildSparkline(metrics.error_rate.comparison) : null), + () => (metrics ? buildSparkline(metrics.error_rate.primary) : null), [metrics], ); const wipSpark = useMemo( () => - metrics ? buildWipSparkline(metrics.wip.comparison, "in_progress") : null, + metrics ? buildWipSparkline(metrics.wip.primary, "in_progress") : null, [metrics], ); @@ -309,13 +317,6 @@ export default function DashboardPage() { [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 ( @@ -329,7 +330,7 @@ export default function DashboardPage() {
-
+

Dashboard @@ -338,12 +339,23 @@ export default function DashboardPage() { Monitor your mission control operations

- {updatedAtLabel ? ( -
- - Updated {updatedAtLabel} -
- ) : null} +
+ { + const nextRange = value as RangeKey; + 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" + /> +
@@ -365,28 +377,24 @@ export default function DashboardPage() { } progress={activeProgress} /> } progress={wipProgress} /> } progress={errorProgress} /> } progress={cycleProgress} /> @@ -396,7 +404,6 @@ export default function DashboardPage() { formatNumber(v)} /> } /> + } /> + formatPercent(v)} /> } /> + formatNumber(v)} /> } /> + +