From 926c1e949794dc26304e3f0596e5a8858c07deaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sanja=20Malovi=C4=87?= Date: Sun, 19 Jul 2026 13:47:11 +0200 Subject: [PATCH 1/4] Add Overview Performance Overview card for Web Vitals (#197). Surface LCP, INP/FID, CLS, and TTFB health on Overview via the existing performance summary API, with rating bars, scoped View report link, and empty/error handling. Co-authored-by: Cursor --- CHANGELOG.md | 2 + apps/dashboard/app/components/Badge.tsx | 2 +- .../overview/OverviewPerformanceCard.tsx | 204 +++++++++++++ .../dashboard/app/dashboard/overview/page.tsx | 9 + apps/dashboard/lib/overview-scope-url.test.ts | 23 ++ .../dashboard/lib/web-vitals-overview.test.ts | 278 ++++++++++++++++++ apps/dashboard/lib/web-vitals-overview.ts | 148 ++++++++++ 7 files changed, 665 insertions(+), 1 deletion(-) create mode 100644 apps/dashboard/app/components/dashboard/overview/OverviewPerformanceCard.tsx create mode 100644 apps/dashboard/lib/web-vitals-overview.test.ts create mode 100644 apps/dashboard/lib/web-vitals-overview.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9389d1ab..f1e0f8b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ Contributors: add user-facing changes under **[Unreleased]** in your PR to `deve ### 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) + ### Fixed ### Changed diff --git a/apps/dashboard/app/components/Badge.tsx b/apps/dashboard/app/components/Badge.tsx index fec77485..de0f623d 100644 --- a/apps/dashboard/app/components/Badge.tsx +++ b/apps/dashboard/app/components/Badge.tsx @@ -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 ( diff --git a/apps/dashboard/app/components/dashboard/overview/OverviewPerformanceCard.tsx b/apps/dashboard/app/components/dashboard/overview/OverviewPerformanceCard.tsx new file mode 100644 index 00000000..28144e3b --- /dev/null +++ b/apps/dashboard/app/components/dashboard/overview/OverviewPerformanceCard.tsx @@ -0,0 +1,204 @@ +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, + overviewPerformanceReportScope, + type OverviewVitalRow, +} from "@/lib/web-vitals-overview"; + +function VitalRatingBar({ row }: { row: OverviewVitalRow }) { + const { ratingDistribution: rating, label } = row; + if (rating.total <= 0) { + return ( +

No samples in this period

+ ); + } + + return ( +
+
+ {rating.goodPct > 0 ? ( +
+ ) : null} + {rating.needsImprovementPct > 0 ? ( +
+ ) : null} + {rating.poorPct > 0 ? ( +
+ ) : null} +
+
+ + {" "} + Good {rating.goodPct.toFixed(0)}% + + + {" "} + Needs improvement {rating.needsImprovementPct.toFixed(0)}% + + + {" "} + Poor {rating.poorPct.toFixed(0)}% + +
+
+ ); +} + +function OverviewPerformancePanel({ + rows, + rangeLabel, + performanceHref, + empty, + loadError, +}: { + rows: OverviewVitalRow[]; + rangeLabel: string; + performanceHref: string; + empty: boolean; + loadError?: boolean; +}) { + return ( + + View report + } + /> + {loadError ? ( +

+ Couldn’t load Web Vitals for this scope. Try refreshing, or open the full Performance + report. +

+ ) : empty ? ( +

+ No Web Vitals yet for this scope. Instrument your browser SDK to capture LCP, INP, CLS, + and TTFB. +

+ ) : ( +
+ {rows.map((row) => ( +
+
+
+

{row.label}

+

+ {row.valueDisplay ?? "—"} +

+
+ {row.ratingLabel && row.badgeTone ? ( + {row.ratingLabel} + ) : ( + Insufficient data + )} +
+ +
+ ))} +
+ )} +
+ ); +} + +export function OverviewPerformanceCardSkeleton() { + return ( + +
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ Loading performance overview… + + ); +} + +/** Async Overview card: Web Vitals snapshot via `GET /api/performance/summary` (#197). */ +export async function OverviewPerformanceCard({ + listScope, + rangeLabel, +}: { + listScope: DashboardListScope; + rangeLabel: string; +}) { + const reportScope = overviewPerformanceReportScope(listScope); + const performanceHref = buildDashboardScopedListHref( + "/dashboard/performance", + reportScope + ); + const summary = await fetchPerformanceSummary( + buildOverviewPerformanceSummaryQuery(listScope) + ); + + if (summary == null) { + return ( + + ); + } + + if (!hasOverviewWebVitals(summary)) { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/dashboard/app/dashboard/overview/page.tsx b/apps/dashboard/app/dashboard/overview/page.tsx index 0e0a7e68..d89ec480 100644 --- a/apps/dashboard/app/dashboard/overview/page.tsx +++ b/apps/dashboard/app/dashboard/overview/page.tsx @@ -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"; @@ -624,6 +628,11 @@ export default async function OverviewPage({ rangeLabel={displayRangeLabel} sessionsHref={buildDashboardScopedListHref("/dashboard/sessions", listScope)} /> +
+ }> + + +
{ ).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", { diff --git a/apps/dashboard/lib/web-vitals-overview.test.ts b/apps/dashboard/lib/web-vitals-overview.test.ts new file mode 100644 index 00000000..029688a3 --- /dev/null +++ b/apps/dashboard/lib/web-vitals-overview.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from "vitest"; +import type { PerformancePageSummary, WebVitalMetricSummary } from "./performance-summary"; +import { + buildOverviewPerformanceSummaryQuery, + classifyOverviewVitalP75, + formatOverviewVitalP75, + hasOverviewWebVitals, + mapOverviewVitalRows, + overviewPerformanceReportScope, + overviewVitalBadgeTone, + overviewVitalRatingLabel, +} from "./web-vitals-overview"; + +function emptyRating(): WebVitalMetricSummary["rating"] { + return { + good: 0, + needsImprovement: 0, + poor: 0, + goodPct: 0, + needsImprovementPct: 0, + poorPct: 0, + total: 0, + }; +} + +function vital( + metric: WebVitalMetricSummary["metric"], + overrides: Partial = {} +): WebVitalMetricSummary { + return { + metric, + sampleCount: 0, + p75: null, + p75Previous: null, + p95: null, + goodPctPrevious: null, + rating: emptyRating(), + series: [], + ...overrides, + }; +} + +function summary( + overrides: Partial = {} +): PerformancePageSummary { + return { + window: { + since: "2026-01-01T00:00:00.000Z", + until: "2026-01-08T00:00:00.000Z", + label: "Last 7 days", + compareLabel: "vs prior 7 days", + }, + chartWindow: { + since: "2026-01-01T00:00:00.000Z", + until: "2026-01-08T00:00:00.000Z", + }, + bucket: "day", + webVitals: { + available: false, + vitals: { + LCP: vital("LCP"), + INP: vital("INP"), + CLS: vital("CLS"), + TTFB: vital("TTFB"), + }, + ...overrides, + }, + requestLatency: { available: false }, + }; +} + +describe("formatOverviewVitalP75", () => { + it("returns null for missing values", () => { + expect(formatOverviewVitalP75("LCP", null)).toBeNull(); + }); + + it("formats duration and CLS", () => { + expect(formatOverviewVitalP75("LCP", 900)).toBe("900ms"); + expect(formatOverviewVitalP75("LCP", 1800)).toBe("1.80s"); + expect(formatOverviewVitalP75("LCP", 2500)).toBe("2.50s"); + expect(formatOverviewVitalP75("CLS", 0.085)).toBe("0.085"); + }); +}); + +describe("classifyOverviewVitalP75", () => { + it("returns null when p75 is missing", () => { + expect(classifyOverviewVitalP75("LCP", null)).toBeNull(); + }); + + it("classifies LCP / INP / CLS / TTFB with standard thresholds", () => { + expect(classifyOverviewVitalP75("LCP", 2000)).toBe("good"); + expect(classifyOverviewVitalP75("LCP", 3000)).toBe("needs-improvement"); + expect(classifyOverviewVitalP75("LCP", 4500)).toBe("poor"); + + expect(classifyOverviewVitalP75("INP", 150)).toBe("good"); + expect(classifyOverviewVitalP75("INP", 350)).toBe("needs-improvement"); + expect(classifyOverviewVitalP75("INP", 600)).toBe("poor"); + + expect(classifyOverviewVitalP75("CLS", 0.05)).toBe("good"); + expect(classifyOverviewVitalP75("CLS", 0.15)).toBe("needs-improvement"); + expect(classifyOverviewVitalP75("CLS", 0.3)).toBe("poor"); + + expect(classifyOverviewVitalP75("TTFB", 500)).toBe("good"); + expect(classifyOverviewVitalP75("TTFB", 1200)).toBe("needs-improvement"); + expect(classifyOverviewVitalP75("TTFB", 2000)).toBe("poor"); + }); +}); + +describe("overviewVitalRatingLabel / overviewVitalBadgeTone", () => { + it("maps ratings to display labels and badge tones", () => { + expect(overviewVitalRatingLabel("good")).toBe("Good"); + expect(overviewVitalRatingLabel("needs-improvement")).toBe("Needs improvement"); + expect(overviewVitalRatingLabel("poor")).toBe("Poor"); + expect(overviewVitalRatingLabel(null)).toBeNull(); + + expect(overviewVitalBadgeTone("good")).toBe("success"); + expect(overviewVitalBadgeTone("needs-improvement")).toBe("warning"); + expect(overviewVitalBadgeTone("poor")).toBe("destructive"); + expect(overviewVitalBadgeTone(null)).toBeNull(); + }); +}); + +describe("buildOverviewPerformanceSummaryQuery", () => { + it("forwards Overview scope without compare params", () => { + const params = buildOverviewPerformanceSummaryQuery({ + app: "web", + environment: "production", + platform: "browser", + release: "1.2.0", + range: "7d", + metricsUntil: "2026-07-19T12:00:00.000Z", + compare: "week", + compareFrom: "2026-07-01T00:00:00.000Z", + compareTo: "2026-07-08T00:00:00.000Z", + }); + expect(params.get("app")).toBe("web"); + expect(params.get("environment")).toBe("production"); + expect(params.get("platform")).toBe("browser"); + expect(params.get("release")).toBe("1.2.0"); + expect(params.get("range")).toBe("7d"); + expect(params.get("metricsUntil")).toBe("2026-07-19T12:00:00.000Z"); + expect(params.get("compare")).toBeNull(); + expect(params.get("compareFrom")).toBeNull(); + expect(params.get("compareTo")).toBeNull(); + }); + + it("includes absolute from/to when present", () => { + const params = buildOverviewPerformanceSummaryQuery({ + from: "2026-07-01T00:00:00.000Z", + to: "2026-07-08T00:00:00.000Z", + }); + expect(params.get("from")).toBe("2026-07-01T00:00:00.000Z"); + expect(params.get("to")).toBe("2026-07-08T00:00:00.000Z"); + }); +}); + +describe("overviewPerformanceReportScope", () => { + it("strips compare params so View report matches the card window", () => { + expect( + overviewPerformanceReportScope({ + app: "web", + environment: "production", + range: "7d", + compare: "week", + compareFrom: "2026-07-01T00:00:00.000Z", + compareTo: "2026-07-08T00:00:00.000Z", + }) + ).toEqual({ + app: "web", + environment: "production", + platform: undefined, + release: undefined, + range: "7d", + from: undefined, + to: undefined, + metricsUntil: undefined, + }); + }); +}); + +describe("hasOverviewWebVitals treats fetch failure separately from empty", () => { + it("returns false for null (caller should show load error, not empty SDK copy)", () => { + expect(hasOverviewWebVitals(null)).toBe(false); + }); +}); + +describe("hasOverviewWebVitals / mapOverviewVitalRows", () => { + it("treats null and unavailable summaries as empty", () => { + expect(hasOverviewWebVitals(null)).toBe(false); + expect(hasOverviewWebVitals(undefined)).toBe(false); + expect(hasOverviewWebVitals(summary())).toBe(false); + }); + + it("maps available vitals with classification and placeholders for missing metrics", () => { + const data = summary({ + available: true, + vitals: { + LCP: vital("LCP", { + sampleCount: 40, + p75: 1800, + rating: { + good: 30, + needsImprovement: 8, + poor: 2, + goodPct: 75, + needsImprovementPct: 20, + poorPct: 5, + total: 40, + }, + }), + INP: vital("INP", { + sampleCount: 20, + p75: 350, + rating: { + good: 5, + needsImprovement: 10, + poor: 5, + goodPct: 25, + needsImprovementPct: 50, + poorPct: 25, + total: 20, + }, + }), + CLS: vital("CLS"), + TTFB: vital("TTFB", { + sampleCount: 10, + p75: 2000, + rating: { + good: 1, + needsImprovement: 2, + poor: 7, + goodPct: 10, + needsImprovementPct: 20, + poorPct: 70, + total: 10, + }, + }), + }, + }); + + expect(hasOverviewWebVitals(data)).toBe(true); + const rows = mapOverviewVitalRows(data); + expect(rows).toHaveLength(4); + + expect(rows[0]).toMatchObject({ + metric: "LCP", + label: "LCP", + valueDisplay: "1.80s", + rating: "good", + ratingLabel: "Good", + badgeTone: "success", + sampleCount: 40, + }); + expect(rows[1]).toMatchObject({ + metric: "INP", + label: "INP / FID", + valueDisplay: "350ms", + rating: "needs-improvement", + ratingLabel: "Needs improvement", + badgeTone: "warning", + }); + expect(rows[2]).toMatchObject({ + metric: "CLS", + valueDisplay: null, + rating: null, + ratingLabel: null, + badgeTone: null, + sampleCount: 0, + }); + expect(rows[3]).toMatchObject({ + metric: "TTFB", + valueDisplay: "2.00s", + rating: "poor", + ratingLabel: "Poor", + badgeTone: "destructive", + }); + }); +}); diff --git a/apps/dashboard/lib/web-vitals-overview.ts b/apps/dashboard/lib/web-vitals-overview.ts new file mode 100644 index 00000000..01812f5d --- /dev/null +++ b/apps/dashboard/lib/web-vitals-overview.ts @@ -0,0 +1,148 @@ +/** Overview Performance card helpers — Web Vitals snapshot (#197). */ + +import { rateWebVital, type WebVitalRating } from "@telemetry-tracker/core"; +import type { DashboardListScope } from "@/lib/overview-scope-url"; +import type { + PerformancePageSummary, + WebVitalMetricSummary, +} from "@/lib/performance-summary"; + +export const OVERVIEW_VITAL_ORDER = ["LCP", "INP", "CLS", "TTFB"] as const; + +export type OverviewVitalKey = (typeof OVERVIEW_VITAL_ORDER)[number]; + +export const OVERVIEW_VITAL_LABELS: Record = { + LCP: "LCP", + INP: "INP / FID", + CLS: "CLS", + TTFB: "TTFB", +}; + +export type OverviewVitalRatingLabel = "Good" | "Needs improvement" | "Poor"; + +export type OverviewVitalBadgeTone = "success" | "warning" | "destructive"; + +export type OverviewVitalRow = { + metric: OverviewVitalKey; + label: string; + /** Formatted p75, or null when insufficient samples. */ + valueDisplay: string | null; + /** Classification of p75; null when no value to rate. */ + rating: WebVitalRating | null; + ratingLabel: OverviewVitalRatingLabel | null; + badgeTone: OverviewVitalBadgeTone | null; + sampleCount: number; + ratingDistribution: WebVitalMetricSummary["rating"]; +}; + +const RATING_LABELS: Record = { + good: "Good", + "needs-improvement": "Needs improvement", + poor: "Poor", +}; + +const RATING_TONES: Record = { + good: "success", + "needs-improvement": "warning", + poor: "destructive", +}; + +function formatDurationMs(ms: number): string { + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} + +/** Format a vital p75 for display; null → insufficient data placeholder. */ +export function formatOverviewVitalP75( + metric: OverviewVitalKey, + value: number | null +): string | null { + if (value == null) return null; + if (metric === "CLS") return value.toFixed(3); + return formatDurationMs(value); +} + +/** + * Classify a vital p75 using standard Web Vitals thresholds. + * FID samples are aggregated as INP server-side; classify with INP bands. + */ +export function classifyOverviewVitalP75( + metric: OverviewVitalKey, + p75: number | null +): WebVitalRating | null { + if (p75 == null || !Number.isFinite(p75)) return null; + return rateWebVital(metric, p75); +} + +export function overviewVitalRatingLabel( + rating: WebVitalRating | null +): OverviewVitalRatingLabel | null { + return rating ? RATING_LABELS[rating] : null; +} + +export function overviewVitalBadgeTone( + rating: WebVitalRating | null +): OverviewVitalBadgeTone | null { + return rating ? RATING_TONES[rating] : null; +} + +/** Build `GET /api/performance/summary` query from Overview list scope (no compare). */ +export function buildOverviewPerformanceSummaryQuery( + scope: DashboardListScope +): URLSearchParams { + const params = new URLSearchParams(); + if (scope.app) params.set("app", scope.app); + if (scope.environment) params.set("environment", scope.environment); + if (scope.platform) params.set("platform", scope.platform); + if (scope.release) params.set("release", scope.release); + if (scope.range) params.set("range", scope.range); + if (scope.from) params.set("from", scope.from); + if (scope.to) params.set("to", scope.to); + if (scope.metricsUntil) params.set("metricsUntil", scope.metricsUntil); + return params; +} + +/** + * Scope for Performance `View report →` — same filters as the card query. + * Compare params are omitted (#197 / #495 out of scope for this card). + */ +export function overviewPerformanceReportScope( + listScope: DashboardListScope +): DashboardListScope { + return { + app: listScope.app, + environment: listScope.environment, + platform: listScope.platform, + release: listScope.release, + range: listScope.range, + from: listScope.from, + to: listScope.to, + metricsUntil: listScope.metricsUntil, + }; +} + +export function hasOverviewWebVitals( + summary: PerformancePageSummary | null | undefined +): boolean { + return summary?.webVitals.available === true; +} + +/** Map API vitals into Overview card rows with explicit empty placeholders. */ +export function mapOverviewVitalRows( + summary: PerformancePageSummary +): OverviewVitalRow[] { + return OVERVIEW_VITAL_ORDER.map((metric) => { + const vital = summary.webVitals.vitals[metric]; + const rating = classifyOverviewVitalP75(metric, vital.p75); + return { + metric, + label: OVERVIEW_VITAL_LABELS[metric], + valueDisplay: formatOverviewVitalP75(metric, vital.p75), + rating, + ratingLabel: overviewVitalRatingLabel(rating), + badgeTone: overviewVitalBadgeTone(rating), + sampleCount: vital.sampleCount, + ratingDistribution: vital.rating, + }; + }); +} From ae0fd1a7c884174825cd7f8155d27abb7e6881db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sanja=20Malovi=C4=87?= Date: Sun, 19 Jul 2026 13:51:43 +0200 Subject: [PATCH 2/4] Align Overview Performance card with resolved KPI window. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calendar/custom compare replaces the current metrics window; express that window as range=custom&from&to for the card fetch and View report link (same pattern as Performance→Events drill-downs). Co-authored-by: Cursor --- .../overview/OverviewPerformanceCard.tsx | 12 ++- .../dashboard/app/dashboard/overview/page.tsx | 18 +++- .../dashboard/lib/web-vitals-overview.test.ts | 93 ++++++++++++++++++- apps/dashboard/lib/web-vitals-overview.ts | 35 ++++++- 4 files changed, 148 insertions(+), 10 deletions(-) diff --git a/apps/dashboard/app/components/dashboard/overview/OverviewPerformanceCard.tsx b/apps/dashboard/app/components/dashboard/overview/OverviewPerformanceCard.tsx index 28144e3b..11c9e2ff 100644 --- a/apps/dashboard/app/components/dashboard/overview/OverviewPerformanceCard.tsx +++ b/apps/dashboard/app/components/dashboard/overview/OverviewPerformanceCard.tsx @@ -10,7 +10,8 @@ import { buildOverviewPerformanceSummaryQuery, hasOverviewWebVitals, mapOverviewVitalRows, - overviewPerformanceReportScope, + resolveOverviewPerformanceScope, + type OverviewMetricsWindow, type OverviewVitalRow, } from "@/lib/web-vitals-overview"; @@ -157,17 +158,20 @@ export function OverviewPerformanceCardSkeleton() { export async function OverviewPerformanceCard({ listScope, rangeLabel, + metricsWindow, }: { listScope: DashboardListScope; rangeLabel: string; + /** Resolved Overview KPI window — required for calendar/custom compare (#495). */ + metricsWindow?: OverviewMetricsWindow | null; }) { - const reportScope = overviewPerformanceReportScope(listScope); + const performanceScope = resolveOverviewPerformanceScope(listScope, metricsWindow); const performanceHref = buildDashboardScopedListHref( "/dashboard/performance", - reportScope + performanceScope ); const summary = await fetchPerformanceSummary( - buildOverviewPerformanceSummaryQuery(listScope) + buildOverviewPerformanceSummaryQuery(performanceScope) ); if (summary == null) { diff --git a/apps/dashboard/app/dashboard/overview/page.tsx b/apps/dashboard/app/dashboard/overview/page.tsx index d89ec480..241f7dc9 100644 --- a/apps/dashboard/app/dashboard/overview/page.tsx +++ b/apps/dashboard/app/dashboard/overview/page.tsx @@ -495,6 +495,18 @@ export default async function OverviewPage({ ? metricsIssueDetailScope : listScope; + // Same window Top Errors detail links use: calendar/custom compare and open-ended + // ranges resolve a KPI window that may differ from the page `range` filter. + const performanceMetricsWindow = + overviewData.metricsSince && + (overviewData.metricsUntil || pageMetricsUntil) && + (isUnselectedTimeRange(parsedRange.key) || !isRollingCompareParam(compare)) + ? { + since: overviewData.metricsSince, + until: overviewData.metricsUntil || pageMetricsUntil!, + } + : null; + const displayRangeLabel = overviewData.rangeLabel ?? parsedRange.label; const errorsDelta = overviewData.errorsLast24h - overviewData.errorsPrevious; const eventsDelta = overviewData.eventsLast24h - overviewData.eventsPrevious; @@ -630,7 +642,11 @@ export default async function OverviewPage({ />
}> - +
diff --git a/apps/dashboard/lib/web-vitals-overview.test.ts b/apps/dashboard/lib/web-vitals-overview.test.ts index 029688a3..651cc9cb 100644 --- a/apps/dashboard/lib/web-vitals-overview.test.ts +++ b/apps/dashboard/lib/web-vitals-overview.test.ts @@ -9,7 +9,9 @@ import { overviewPerformanceReportScope, overviewVitalBadgeTone, overviewVitalRatingLabel, + resolveOverviewPerformanceScope, } from "./web-vitals-overview"; +import { buildDashboardScopedListHref } from "./overview-scope-url"; function emptyRating(): WebVitalMetricSummary["rating"] { return { @@ -121,7 +123,7 @@ describe("overviewVitalRatingLabel / overviewVitalBadgeTone", () => { }); describe("buildOverviewPerformanceSummaryQuery", () => { - it("forwards Overview scope without compare params", () => { + it("forwards page-range scope without compare params", () => { const params = buildOverviewPerformanceSummaryQuery({ app: "web", environment: "production", @@ -154,6 +156,95 @@ describe("buildOverviewPerformanceSummaryQuery", () => { }); }); +describe("resolveOverviewPerformanceScope", () => { + const listScope = { + app: "web", + environment: "production", + platform: "browser", + release: "1.2.0", + range: "7d", + compare: "week" as const, + }; + + it("maps compare=week resolved KPI window to custom from/to and drops compare", () => { + const scope = resolveOverviewPerformanceScope(listScope, { + since: "2026-07-13T00:00:00.000Z", + until: "2026-07-19T15:30:00.000Z", + }); + expect(scope).toEqual({ + app: "web", + environment: "production", + platform: "browser", + release: "1.2.0", + range: "custom", + from: "2026-07-13T00:00:00.000Z", + to: "2026-07-19T15:30:00.000Z", + }); + const params = buildOverviewPerformanceSummaryQuery(scope); + expect(params.get("range")).toBe("custom"); + expect(params.get("from")).toBe("2026-07-13T00:00:00.000Z"); + expect(params.get("to")).toBe("2026-07-19T15:30:00.000Z"); + expect(params.get("compare")).toBeNull(); + expect( + buildDashboardScopedListHref("/dashboard/performance", scope) + ).toBe( + "/dashboard/performance?app=web&environment=production&platform=browser&release=1.2.0&range=custom&from=2026-07-13T00%3A00%3A00.000Z&to=2026-07-19T15%3A30%3A00.000Z" + ); + }); + + it("maps custom comparison current window to custom from/to and drops compareFrom/To", () => { + const scope = resolveOverviewPerformanceScope( + { + app: "api", + range: "custom", + from: "2026-07-10T00:00:00.000Z", + to: "2026-07-17T00:00:00.000Z", + compare: "custom", + compareFrom: "2026-07-03T00:00:00.000Z", + compareTo: "2026-07-10T00:00:00.000Z", + }, + { + since: "2026-07-10T00:00:00.000Z", + until: "2026-07-17T00:00:00.000Z", + } + ); + expect(scope).toEqual({ + app: "api", + environment: undefined, + platform: undefined, + release: undefined, + range: "custom", + from: "2026-07-10T00:00:00.000Z", + to: "2026-07-17T00:00:00.000Z", + }); + const params = buildOverviewPerformanceSummaryQuery(scope); + expect(params.get("range")).toBe("custom"); + expect(params.get("from")).toBe("2026-07-10T00:00:00.000Z"); + expect(params.get("to")).toBe("2026-07-17T00:00:00.000Z"); + expect(params.get("compare")).toBeNull(); + expect(params.get("compareFrom")).toBeNull(); + expect(params.get("compareTo")).toBeNull(); + expect( + buildDashboardScopedListHref("/dashboard/performance", scope) + ).toBe( + "/dashboard/performance?app=api&range=custom&from=2026-07-10T00%3A00%3A00.000Z&to=2026-07-17T00%3A00%3A00.000Z" + ); + }); + + it("falls back to page-range scope (no compare) when metrics window is absent", () => { + expect(resolveOverviewPerformanceScope(listScope, null)).toEqual({ + app: "web", + environment: "production", + platform: "browser", + release: "1.2.0", + range: "7d", + from: undefined, + to: undefined, + metricsUntil: undefined, + }); + }); +}); + describe("overviewPerformanceReportScope", () => { it("strips compare params so View report matches the card window", () => { expect( diff --git a/apps/dashboard/lib/web-vitals-overview.ts b/apps/dashboard/lib/web-vitals-overview.ts index 01812f5d..253bcf64 100644 --- a/apps/dashboard/lib/web-vitals-overview.ts +++ b/apps/dashboard/lib/web-vitals-overview.ts @@ -1,7 +1,10 @@ /** Overview Performance card helpers — Web Vitals snapshot (#197). */ import { rateWebVital, type WebVitalRating } from "@telemetry-tracker/core"; -import type { DashboardListScope } from "@/lib/overview-scope-url"; +import { + scopeForPerformanceEventsDrillDown, + type DashboardListScope, +} from "@/lib/overview-scope-url"; import type { PerformancePageSummary, WebVitalMetricSummary, @@ -35,6 +38,11 @@ export type OverviewVitalRow = { ratingDistribution: WebVitalMetricSummary["rating"]; }; +export type OverviewMetricsWindow = { + since: string; + until: string; +}; + const RATING_LABELS: Record = { good: "Good", "needs-improvement": "Needs improvement", @@ -86,7 +94,26 @@ export function overviewVitalBadgeTone( return rating ? RATING_TONES[rating] : null; } -/** Build `GET /api/performance/summary` query from Overview list scope (no compare). */ +/** + * Align Performance card + View report with Overview's resolved KPI window. + * + * Calendar/custom compare (#495) replace the current metrics window (e.g. + * `compare=week` → "This week"). Performance summary ignores Overview's + * compare semantics unless we either forward compare* or express the resolved + * window as `range=custom&from&to`. Match Performance→Events drill-downs: + * convert the resolved window and drop compare*. + */ +export function resolveOverviewPerformanceScope( + listScope: DashboardListScope, + metricsWindow?: OverviewMetricsWindow | null +): DashboardListScope { + if (metricsWindow?.since && metricsWindow?.until) { + return scopeForPerformanceEventsDrillDown(listScope, metricsWindow); + } + return overviewPerformanceReportScope(listScope); +} + +/** Build `GET /api/performance/summary` query from a resolved Performance scope. */ export function buildOverviewPerformanceSummaryQuery( scope: DashboardListScope ): URLSearchParams { @@ -103,8 +130,8 @@ export function buildOverviewPerformanceSummaryQuery( } /** - * Scope for Performance `View report →` — same filters as the card query. - * Compare params are omitted (#197 / #495 out of scope for this card). + * Scope for Performance `View report →` when no resolved metrics window is + * provided — keep page range filters, omit compare* (card is a snapshot). */ export function overviewPerformanceReportScope( listScope: DashboardListScope From dfabdbe283d21932f8463beb88aa70bffa2c4e59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sanja=20Malovi=C4=87?= Date: Sun, 19 Jul 2026 13:54:55 +0200 Subject: [PATCH 3/4] Always drive Performance Overview card from metricsSince/Until. Remove the calendar-compare-only gate so rolling presets also use the resolved KPI window as range=custom&from&to for fetch and View report. Co-authored-by: Cursor --- .../overview/OverviewPerformanceCard.tsx | 13 +++++-- .../dashboard/app/dashboard/overview/page.tsx | 19 ++++------ .../dashboard/lib/web-vitals-overview.test.ts | 35 +++++++++++++++++++ apps/dashboard/lib/web-vitals-overview.ts | 9 +++-- 4 files changed, 56 insertions(+), 20 deletions(-) diff --git a/apps/dashboard/app/components/dashboard/overview/OverviewPerformanceCard.tsx b/apps/dashboard/app/components/dashboard/overview/OverviewPerformanceCard.tsx index 11c9e2ff..d383c46c 100644 --- a/apps/dashboard/app/components/dashboard/overview/OverviewPerformanceCard.tsx +++ b/apps/dashboard/app/components/dashboard/overview/OverviewPerformanceCard.tsx @@ -158,13 +158,20 @@ export function OverviewPerformanceCardSkeleton() { export async function OverviewPerformanceCard({ listScope, rangeLabel, - metricsWindow, + metricsSince, + metricsUntil, }: { listScope: DashboardListScope; rangeLabel: string; - /** Resolved Overview KPI window — required for calendar/custom compare (#495). */ - metricsWindow?: OverviewMetricsWindow | null; + /** 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", diff --git a/apps/dashboard/app/dashboard/overview/page.tsx b/apps/dashboard/app/dashboard/overview/page.tsx index 241f7dc9..a21b77ea 100644 --- a/apps/dashboard/app/dashboard/overview/page.tsx +++ b/apps/dashboard/app/dashboard/overview/page.tsx @@ -495,17 +495,11 @@ export default async function OverviewPage({ ? metricsIssueDetailScope : listScope; - // Same window Top Errors detail links use: calendar/custom compare and open-ended - // ranges resolve a KPI window that may differ from the page `range` filter. - const performanceMetricsWindow = - overviewData.metricsSince && - (overviewData.metricsUntil || pageMetricsUntil) && - (isUnselectedTimeRange(parsedRange.key) || !isRollingCompareParam(compare)) - ? { - since: overviewData.metricsSince, - until: overviewData.metricsUntil || pageMetricsUntil!, - } - : null; + // 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; @@ -645,7 +639,8 @@ export default async function OverviewPage({
diff --git a/apps/dashboard/lib/web-vitals-overview.test.ts b/apps/dashboard/lib/web-vitals-overview.test.ts index 651cc9cb..32215e86 100644 --- a/apps/dashboard/lib/web-vitals-overview.test.ts +++ b/apps/dashboard/lib/web-vitals-overview.test.ts @@ -231,6 +231,41 @@ describe("resolveOverviewPerformanceScope", () => { ); }); + it("maps rolling/default 7d KPI window to custom from/to (not list range=7d)", () => { + const scope = resolveOverviewPerformanceScope( + { + app: "web", + environment: "production", + range: "7d", + compare: "previous", + }, + { + since: "2026-07-12T12:00:00.000Z", + until: "2026-07-19T12:00:00.000Z", + } + ); + expect(scope).toEqual({ + app: "web", + environment: "production", + platform: undefined, + release: undefined, + range: "custom", + from: "2026-07-12T12:00:00.000Z", + to: "2026-07-19T12:00:00.000Z", + }); + expect(scope.range).not.toBe("7d"); + const params = buildOverviewPerformanceSummaryQuery(scope); + expect(params.get("range")).toBe("custom"); + expect(params.get("from")).toBe("2026-07-12T12:00:00.000Z"); + expect(params.get("to")).toBe("2026-07-19T12:00:00.000Z"); + expect(params.get("compare")).toBeNull(); + expect( + buildDashboardScopedListHref("/dashboard/performance", scope) + ).toBe( + "/dashboard/performance?app=web&environment=production&range=custom&from=2026-07-12T12%3A00%3A00.000Z&to=2026-07-19T12%3A00%3A00.000Z" + ); + }); + it("falls back to page-range scope (no compare) when metrics window is absent", () => { expect(resolveOverviewPerformanceScope(listScope, null)).toEqual({ app: "web", diff --git a/apps/dashboard/lib/web-vitals-overview.ts b/apps/dashboard/lib/web-vitals-overview.ts index 253bcf64..be980839 100644 --- a/apps/dashboard/lib/web-vitals-overview.ts +++ b/apps/dashboard/lib/web-vitals-overview.ts @@ -97,11 +97,10 @@ export function overviewVitalBadgeTone( /** * Align Performance card + View report with Overview's resolved KPI window. * - * Calendar/custom compare (#495) replace the current metrics window (e.g. - * `compare=week` → "This week"). Performance summary ignores Overview's - * compare semantics unless we either forward compare* or express the resolved - * window as `range=custom&from&to`. Match Performance→Events drill-downs: - * convert the resolved window and drop compare*. + * Always express `metricsSince`/`metricsUntil` as `range=custom&from&to` (same + * pattern as Performance→Events drill-downs). Do not forward compare* — once the + * current window is explicit, baseline params are unnecessary. Falls back to the + * page list range only when Overview did not return a metrics window. */ export function resolveOverviewPerformanceScope( listScope: DashboardListScope, From 52ae7a29b3154a976bcb8ff882cf2473a6ae3455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sanja=20Malovi=C4=87?= Date: Sun, 19 Jul 2026 13:59:50 +0200 Subject: [PATCH 4/4] Release prep v1.17.2: CHANGELOG + API_VERSION for Performance Overview card (#197). Co-authored-by: Cursor --- CHANGELOG.md | 10 ++++++++-- apps/api/src/generated/api-version.ts | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1e0f8b0..88fd5e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,6 @@ Contributors: add user-facing changes under **[Unreleased]** in your PR to `deve ### 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) - ### Fixed ### Changed @@ -23,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 diff --git a/apps/api/src/generated/api-version.ts b/apps/api/src/generated/api-version.ts index 173a9c43..6d31470c 100644 --- a/apps/api/src/generated/api-version.ts +++ b/apps/api/src/generated/api-version.ts @@ -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";