Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ Contributors: add user-facing changes under **[Unreleased]** in your PR to `deve

---

## [1.17.2] - 2026-07-19

### Added

- **Performance Overview card** — Overview Web Vitals snapshot (LCP, INP/FID, CLS, TTFB) with Good / Needs improvement / Poor classification, rating distribution bars, scoped `View report →` to Performance, and empty-state handling when no vitals exist ([#197](https://github.com/Telemetry-Tracker/telemetry-tracker/issues/197); milestone v1.17.x — Performance Intelligence)

---

## [1.17.1] - 2026-07-19

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/generated/api-version.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
/** Generated at build time from CHANGELOG (/Users/unjica/Documents/GitHub/telemetry-tracker/CHANGELOG.md). Do not edit manually. */
export const API_VERSION = "1.17.1";
export const API_VERSION = "1.17.2";
2 changes: 1 addition & 1 deletion apps/dashboard/app/components/Badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export function Badge({
variant = "outline",
}: {
children: string;
variant?: "outline" | "secondary" | "brand" | "success" | "destructive";
variant?: "outline" | "secondary" | "brand" | "success" | "warning" | "destructive";
}) {
return (
<ShadBadge variant={variant} className="font-mono text-[10px] font-normal uppercase tracking-wider">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import {
AnalyticsPanel,
AnalyticsPanelHeader,
AnalyticsViewAllLink,
} from "@/app/components/dashboard/analytics-ui";
import { Badge } from "@/app/components/Badge";
import { fetchPerformanceSummary } from "@/lib/performance-summary";
import { buildDashboardScopedListHref, type DashboardListScope } from "@/lib/overview-scope-url";
import {
buildOverviewPerformanceSummaryQuery,
hasOverviewWebVitals,
mapOverviewVitalRows,
resolveOverviewPerformanceScope,
type OverviewMetricsWindow,
type OverviewVitalRow,
} from "@/lib/web-vitals-overview";

function VitalRatingBar({ row }: { row: OverviewVitalRow }) {
const { ratingDistribution: rating, label } = row;
if (rating.total <= 0) {
return (
<p className="text-[12px] text-muted-foreground">No samples in this period</p>
);
}

return (
<div className="space-y-1.5">
<div
className="flex h-2 w-full overflow-hidden rounded-full bg-muted/40"
role="img"
aria-label={`${label} rating distribution: ${rating.goodPct.toFixed(0)}% good, ${rating.needsImprovementPct.toFixed(0)}% needs improvement, ${rating.poorPct.toFixed(0)}% poor`}
>
{rating.goodPct > 0 ? (
<div
className="h-full bg-success"
style={{ width: `${rating.goodPct}%` }}
title={`Good ${rating.goodPct.toFixed(1)}%`}
/>
) : null}
{rating.needsImprovementPct > 0 ? (
<div
className="h-full bg-warning"
style={{ width: `${rating.needsImprovementPct}%` }}
title={`Needs improvement ${rating.needsImprovementPct.toFixed(1)}%`}
/>
) : null}
{rating.poorPct > 0 ? (
<div
className="h-full bg-destructive"
style={{ width: `${rating.poorPct}%` }}
title={`Poor ${rating.poorPct.toFixed(1)}%`}
/>
) : null}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-muted-foreground">
<span>
<span className="inline-block h-1.5 w-1.5 rounded-full bg-success align-middle" aria-hidden />{" "}
Good {rating.goodPct.toFixed(0)}%
</span>
<span>
<span className="inline-block h-1.5 w-1.5 rounded-full bg-warning align-middle" aria-hidden />{" "}
Needs improvement {rating.needsImprovementPct.toFixed(0)}%
</span>
<span>
<span
className="inline-block h-1.5 w-1.5 rounded-full bg-destructive align-middle"
aria-hidden
/>{" "}
Poor {rating.poorPct.toFixed(0)}%
</span>
</div>
</div>
);
}

function OverviewPerformancePanel({
rows,
rangeLabel,
performanceHref,
empty,
loadError,
}: {
rows: OverviewVitalRow[];
rangeLabel: string;
performanceHref: string;
empty: boolean;
loadError?: boolean;
}) {
return (
<AnalyticsPanel aria-label="Performance overview">
<AnalyticsPanelHeader
title="Performance Overview"
description={`Web Vitals health · ${rangeLabel.toLowerCase()}`}
action={
<AnalyticsViewAllLink href={performanceHref}>View report</AnalyticsViewAllLink>
}
/>
{loadError ? (
<p className="px-4 py-8 text-center text-sm text-muted-foreground sm:px-5">
Couldn’t load Web Vitals for this scope. Try refreshing, or open the full Performance
report.
</p>
) : empty ? (
<p className="px-4 py-8 text-center text-sm text-muted-foreground sm:px-5">
No Web Vitals yet for this scope. Instrument your browser SDK to capture LCP, INP, CLS,
and TTFB.
</p>
) : (
<div className="divide-y divide-border">
{rows.map((row) => (
<div key={row.metric} className="px-4 py-3.5 sm:px-5">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-[12px] font-medium">{row.label}</p>
<p className="mt-0.5 text-lg font-semibold tabular-nums tracking-tight">
{row.valueDisplay ?? "—"}
</p>
</div>
{row.ratingLabel && row.badgeTone ? (
<Badge variant={row.badgeTone}>{row.ratingLabel}</Badge>
) : (
<span className="text-[11px] text-muted-foreground">Insufficient data</span>
)}
</div>
<VitalRatingBar row={row} />
</div>
))}
</div>
)}
</AnalyticsPanel>
);
}

export function OverviewPerformanceCardSkeleton() {
return (
<AnalyticsPanel aria-busy="true" aria-live="polite" aria-label="Loading performance overview">
<div className="border-b border-border px-4 py-3 sm:px-5">
<div className="h-4 w-40 animate-pulse rounded bg-muted/50" />
<div className="mt-2 h-3 w-56 animate-pulse rounded bg-muted/40" />
</div>
<div className="space-y-4 px-4 py-4 sm:px-5">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="flex justify-between gap-2">
<div className="h-8 w-24 animate-pulse rounded bg-muted/40" />
<div className="h-5 w-16 animate-pulse rounded bg-muted/40" />
</div>
<div className="h-2 w-full animate-pulse rounded-full bg-muted/40" />
</div>
))}
</div>
<span className="sr-only">Loading performance overview…</span>
</AnalyticsPanel>
);
}

/** Async Overview card: Web Vitals snapshot via `GET /api/performance/summary` (#197). */
export async function OverviewPerformanceCard({
listScope,
rangeLabel,
metricsSince,
metricsUntil,
}: {
listScope: DashboardListScope;
rangeLabel: string;
/** Resolved Overview KPI window (`metricsSince` from `/api/overview`). */
metricsSince?: string | null;
/** Resolved Overview KPI window (`metricsUntil` from `/api/overview`). */
metricsUntil?: string | null;
}) {
const metricsWindow: OverviewMetricsWindow | null =
metricsSince && metricsUntil
? { since: metricsSince, until: metricsUntil }
: null;
const performanceScope = resolveOverviewPerformanceScope(listScope, metricsWindow);
const performanceHref = buildDashboardScopedListHref(
"/dashboard/performance",
performanceScope
);
const summary = await fetchPerformanceSummary(
buildOverviewPerformanceSummaryQuery(performanceScope)
);

if (summary == null) {
return (
<OverviewPerformancePanel
rows={[]}
rangeLabel={rangeLabel}
performanceHref={performanceHref}
empty={false}
loadError
/>
);
}

if (!hasOverviewWebVitals(summary)) {
return (
<OverviewPerformancePanel
rows={[]}
rangeLabel={summary.window.label || rangeLabel}
performanceHref={performanceHref}
empty
/>
);
}

return (
<OverviewPerformancePanel
rows={mapOverviewVitalRows(summary)}
rangeLabel={summary.window.label || rangeLabel}
performanceHref={performanceHref}
empty={false}
/>
);
}
20 changes: 20 additions & 0 deletions apps/dashboard/app/dashboard/overview/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ import {
OverviewRecentSessionsPanel,
OverviewTopErrorsPanel,
} from "@/app/components/dashboard/overview/OverviewBreakdownGrid";
import {
OverviewPerformanceCard,
OverviewPerformanceCardSkeleton,
} from "@/app/components/dashboard/overview/OverviewPerformanceCard";
import { mergeListQuery, redirectHrefIfMissingTimeRange, redirectHrefForMetricsUntil } from "@/lib/list-filters-url";
import { parseOverviewListPageSize, parsePageParam } from "@/lib/pagination";
import type { OverviewApiResponse, OverviewHealth, OverviewKpiSparklines, OverviewWorkspaceTelemetry } from "@/lib/overview-api";
Expand Down Expand Up @@ -491,6 +495,12 @@ export default async function OverviewPage({
? metricsIssueDetailScope
: listScope;

// Always drive the Performance card from Overview's resolved KPI window so
// calendar/custom compare and rolling presets (e.g. 7d) stay aligned with KPIs.
const performanceMetricsSince = overviewData.metricsSince ?? null;
const performanceMetricsUntil =
overviewData.metricsUntil || pageMetricsUntil || null;

const displayRangeLabel = overviewData.rangeLabel ?? parsedRange.label;
const errorsDelta = overviewData.errorsLast24h - overviewData.errorsPrevious;
const eventsDelta = overviewData.eventsLast24h - overviewData.eventsPrevious;
Expand Down Expand Up @@ -624,6 +634,16 @@ export default async function OverviewPage({
rangeLabel={displayRangeLabel}
sessionsHref={buildDashboardScopedListHref("/dashboard/sessions", listScope)}
/>
<div className="lg:col-start-2">
<Suspense fallback={<OverviewPerformanceCardSkeleton />}>
<OverviewPerformanceCard
listScope={listScope}
rangeLabel={displayRangeLabel}
metricsSince={performanceMetricsSince}
metricsUntil={performanceMetricsUntil}
/>
</Suspense>
</div>
</section>

<OverviewExtraCharts
Expand Down
23 changes: 23 additions & 0 deletions apps/dashboard/lib/overview-scope-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,29 @@ describe("buildDashboardScopedListHref", () => {
).toBe("/dashboard/events?app=web&environment=production&platform=web&release=1.0.0");
});

it("preserves Overview scope on Performance deep links", () => {
expect(
buildDashboardScopedListHref("/dashboard/performance", {
app: "web",
environment: "production",
platform: "browser",
range: "7d",
})
).toBe(
"/dashboard/performance?app=web&environment=production&platform=browser&range=7d"
);
});

it("omits compare from Performance links when not provided", () => {
expect(
buildDashboardScopedListHref("/dashboard/performance", {
app: "web",
range: "7d",
compare: undefined,
})
).toBe("/dashboard/performance?app=web&range=7d");
});

it("preserves the Unknown release sentinel", () => {
expect(
buildDashboardScopedListHref("/dashboard/errors", {
Expand Down
Loading
Loading