diff --git a/apps/web/package.json b/apps/web/package.json
index 82634c85..f1cabcac 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -30,6 +30,7 @@
"@openstatus/ui": "workspace:*",
"@openstatus/upstash": "workspace:*",
"@openstatus/utils": "workspace:*",
+ "@openstatus/rum": "workspace:*",
"@sentry/integrations": "7.100.1",
"@sentry/nextjs": "7.100.1",
"@stripe/stripe-js": "2.1.6",
diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/category-bar.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/category-bar.tsx
new file mode 100644
index 00000000..a5b0a51a
--- /dev/null
+++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/category-bar.tsx
@@ -0,0 +1,112 @@
+// TODO: move to @/components folder
+
+import React from "react";
+
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@openstatus/ui";
+
+import { cn } from "@/lib/utils";
+
+const MAX_VALUE_RATIO = 1.3; // avoiding Infinity as number
+
+interface CategoryBarProps {
+ values: {
+ color: string;
+ min: number;
+ max: number;
+ }[];
+ marker: number;
+}
+
+export function CategoryBar({ values, marker }: CategoryBarProps) {
+ const getMarkerColor = React.useCallback(() => {
+ for (const value of values) {
+ if (marker >= value.min && marker <= value.max) {
+ return value.color;
+ }
+ }
+ return "bg-gray-500";
+ }, [values, marker]);
+
+ /**
+ * Get the max value from the values array
+ * If the max value is not finite, calculate the max value based on the ratio
+ */
+ const getMaxValue = React.useCallback(() => {
+ const maxValue = values.reduce((acc, value) => {
+ if (Number.isFinite(value.max)) return Math.max(acc, value.max);
+ return acc * MAX_VALUE_RATIO;
+ }, 0);
+ return Math.max(maxValue, marker);
+ }, [values, marker]);
+
+ const valuesWithPercentage = React.useMemo(
+ () =>
+ values.map((value) => {
+ const max = Number.isFinite(value.max) ? value.max : getMaxValue();
+ return {
+ ...value,
+ percentage: (max - value.min) / getMaxValue(),
+ };
+ }),
+ [values, getMaxValue],
+ );
+
+ return (
+
-
-
-
-
-
-
+
+ {webVitalEvents.map((event) => (
+
+ ))}
);
}
diff --git a/packages/rum/package.json b/packages/rum/package.json
new file mode 100644
index 00000000..41dcc509
--- /dev/null
+++ b/packages/rum/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@openstatus/rum",
+ "version": "1.0.0",
+ "description": "",
+ "main": "src/index.ts",
+ "scripts": {},
+ "dependencies": {
+ "zod": "3.22.4"
+ },
+ "devDependencies": {
+ "@openstatus/tsconfig": "workspace:*",
+ "typescript": "5.4.5"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC"
+}
diff --git a/packages/rum/src/config.ts b/packages/rum/src/config.ts
new file mode 100644
index 00000000..08963a08
--- /dev/null
+++ b/packages/rum/src/config.ts
@@ -0,0 +1,169 @@
+import type { WebVitalsConfig } from "./types";
+
+export const webVitalEvents = [
+ "CLS",
+ "FCP",
+ "FID",
+ "INP",
+ "LCP",
+ "TTFB",
+] as const;
+
+export const webVitalsConfig: WebVitalsConfig = {
+ CLS: {
+ unit: "",
+ label: "Cumulative Layout Shift",
+ description:
+ "CLS measures the sum of all individual layout shift scores for every unexpected layout shift that occurs during the entire lifespan of the page.",
+ values: [
+ {
+ type: "good",
+ label: "Good",
+ min: 0,
+ max: 0.1,
+ },
+ {
+ type: "needs-improvement",
+ label: "Needs Improvement",
+ min: 0.1,
+ max: 0.25,
+ },
+ {
+ type: "poor",
+ label: "Poor",
+ min: 0.25,
+ max: Infinity,
+ },
+ ],
+ },
+ FCP: {
+ unit: "ms",
+ label: "First Contentful Paint",
+ description:
+ "FCP measures the time from when the page starts loading to when any part of the page's content is rendered on the screen.",
+ values: [
+ {
+ type: "good",
+ label: "Good",
+ min: 0,
+ max: 1000,
+ },
+ {
+ type: "needs-improvement",
+ label: "Needs Improvement",
+ min: 1000,
+ max: 2500,
+ },
+ {
+ type: "poor",
+ label: "Poor",
+ min: 2500,
+ max: Infinity,
+ },
+ ],
+ },
+ FID: {
+ unit: "ms",
+ label: "First Input Delay",
+ description:
+ "FID measures the time from when a user first interacts with a page to the time when the browser is actually able to respond to that interaction.",
+ values: [
+ {
+ type: "good",
+ label: "Good",
+ min: 0,
+ max: 100,
+ },
+ {
+ type: "needs-improvement",
+ label: "Needs Improvement",
+ min: 100,
+ max: 300,
+ },
+ {
+ type: "poor",
+ label: "Poor",
+ min: 300,
+ max: Infinity,
+ },
+ ],
+ },
+ INP: {
+ unit: "ms",
+ label: "Input Delay",
+ description:
+ "INP measures the time from when a user first interacts with a page to the time when the browser is actually able to respond to that interaction.",
+ values: [
+ {
+ type: "good",
+ label: "Good",
+ min: 0,
+ max: 50,
+ },
+ {
+ type: "needs-improvement",
+ label: "Needs Improvement",
+ min: 50,
+ max: 250,
+ },
+ {
+ type: "poor",
+ label: "Poor",
+ min: 250,
+ max: Infinity,
+ },
+ ],
+ },
+ LCP: {
+ unit: "ms",
+ label: "Largest Contentful Paint",
+ description:
+ "LCP measures the time from when the page starts loading to when the largest content element is rendered on the screen.",
+ values: [
+ {
+ type: "good",
+ label: "Good",
+ min: 0,
+ max: 2500,
+ },
+ {
+ type: "needs-improvement",
+ label: "Needs Improvement",
+ min: 2500,
+ max: 4000,
+ },
+ {
+ type: "poor",
+ label: "Poor",
+ min: 4000,
+ max: Infinity,
+ },
+ ],
+ },
+ TTFB: {
+ unit: "ms",
+ label: "Time to First Byte",
+ description:
+ "TTFB measures the time from when the browser starts requesting a page to when the first byte of the page is received by the browser.",
+ values: [
+ {
+ type: "good",
+ label: "Good",
+ min: 0,
+ max: 200,
+ },
+ {
+ type: "needs-improvement",
+ label: "Needs Improvement",
+ min: 200,
+ max: 500,
+ },
+ {
+ type: "poor",
+ label: "Poor",
+ min: 500,
+ max: Infinity,
+ },
+ ],
+ },
+};
diff --git a/packages/rum/src/index.ts b/packages/rum/src/index.ts
new file mode 100644
index 00000000..2d33a833
--- /dev/null
+++ b/packages/rum/src/index.ts
@@ -0,0 +1,3 @@
+export * from "./config";
+export * from "./utils";
+export * from "./types";
diff --git a/packages/rum/src/types.ts b/packages/rum/src/types.ts
new file mode 100644
index 00000000..5888906d
--- /dev/null
+++ b/packages/rum/src/types.ts
@@ -0,0 +1,22 @@
+import type { webVitalEvents } from "./config";
+
+export type WebVitalEvents = (typeof webVitalEvents)[number];
+
+export type WebVitalsValueTypes = "good" | "needs-improvement" | "poor";
+
+export type WebVitalsValues = {
+ type: WebVitalsValueTypes;
+ label: string;
+ min: number;
+ max: number;
+}[];
+
+export type WebVitalsConfig = Record<
+ WebVitalEvents,
+ {
+ unit: string;
+ label: string;
+ description: string;
+ values: WebVitalsValues;
+ }
+>;
diff --git a/packages/rum/src/utils.ts b/packages/rum/src/utils.ts
new file mode 100644
index 00000000..38bb727d
--- /dev/null
+++ b/packages/rum/src/utils.ts
@@ -0,0 +1,15 @@
+import type { WebVitalsValueTypes } from "./types";
+
+export function getColorByType(type: WebVitalsValueTypes) {
+ switch (type) {
+ case "good":
+ return "bg-green-500";
+ case "needs-improvement":
+ return "bg-yellow-500";
+ case "poor":
+ return "bg-rose-500";
+ default:
+ const _check: never = type;
+ return _check;
+ }
+}
diff --git a/packages/rum/tsconfig.json b/packages/rum/tsconfig.json
new file mode 100644
index 00000000..aed15c74
--- /dev/null
+++ b/packages/rum/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "@openstatus/tsconfig/base.json",
+ "include": ["src", "*.ts"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8ba57838..2fe7f11b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -272,6 +272,9 @@ importers:
'@openstatus/react':
specifier: workspace:*
version: link:../../packages/react
+ '@openstatus/rum':
+ specifier: workspace:*
+ version: link:../../packages/rum
'@openstatus/tinybird':
specifier: workspace:*
version: link:../../packages/tinybird
@@ -894,6 +897,19 @@ importers:
specifier: 5.4.5
version: 5.4.5
+ packages/rum:
+ dependencies:
+ zod:
+ specifier: 3.22.4
+ version: 3.22.4
+ devDependencies:
+ '@openstatus/tsconfig':
+ specifier: workspace:*
+ version: link:../tsconfig
+ typescript:
+ specifier: 5.4.5
+ version: 5.4.5
+
packages/tinybird:
dependencies:
'@chronark/zod-bird':