diff --git a/CHANGELOG.md b/CHANGELOG.md index 9389d1ab..88fd5e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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"; 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..d383c46c --- /dev/null +++ b/apps/dashboard/app/components/dashboard/overview/OverviewPerformanceCard.tsx @@ -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 ( +

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, + 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 ( + + ); + } + + 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..a21b77ea 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"; @@ -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; @@ -624,6 +634,16 @@ 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..32215e86 --- /dev/null +++ b/apps/dashboard/lib/web-vitals-overview.test.ts @@ -0,0 +1,404 @@ +import { describe, expect, it } from "vitest"; +import type { PerformancePageSummary, WebVitalMetricSummary } from "./performance-summary"; +import { + buildOverviewPerformanceSummaryQuery, + classifyOverviewVitalP75, + formatOverviewVitalP75, + hasOverviewWebVitals, + mapOverviewVitalRows, + overviewPerformanceReportScope, + overviewVitalBadgeTone, + overviewVitalRatingLabel, + resolveOverviewPerformanceScope, +} from "./web-vitals-overview"; +import { buildDashboardScopedListHref } from "./overview-scope-url"; + +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 page-range 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("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("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", + 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( + 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..be980839 --- /dev/null +++ b/apps/dashboard/lib/web-vitals-overview.ts @@ -0,0 +1,174 @@ +/** Overview Performance card helpers — Web Vitals snapshot (#197). */ + +import { rateWebVital, type WebVitalRating } from "@telemetry-tracker/core"; +import { + scopeForPerformanceEventsDrillDown, + 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"]; +}; + +export type OverviewMetricsWindow = { + since: string; + until: string; +}; + +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; +} + +/** + * Align Performance card + View report with Overview's resolved KPI window. + * + * 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, + 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 { + 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 →` when no resolved metrics window is + * provided — keep page range filters, omit compare* (card is a snapshot). + */ +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, + }; + }); +}