-
+
{loading ? (
diff --git a/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/Actions/ActionId/page/VerifiedTable/VerifiedRow/index.tsx b/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/Actions/ActionId/page/VerifiedTable/VerifiedRow/index.tsx
index 81a12d3a23..c0680089cf 100644
--- a/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/Actions/ActionId/page/VerifiedTable/VerifiedRow/index.tsx
+++ b/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/Actions/ActionId/page/VerifiedTable/VerifiedRow/index.tsx
@@ -34,7 +34,7 @@ export const VerifiedRow = (props: {
{`${nullifier.nullifier_hash.slice(0, 10)}...${nullifier.nullifier_hash.slice(-8)}`}
+ >{`${nullifier.nullifier_hash.slice(0, 12)}...${nullifier.nullifier_hash.slice(-8)}`}
{
switch (column) {
case "human":
- return "Human";
+ return "Nullifier";
case "uses":
return "Uses";
case "time":
@@ -30,8 +30,15 @@ export const VerifiedTable = (props: {
columns: VerifiedTableColumn[];
showIcons?: boolean;
showCount?: boolean;
+ showPagination?: boolean;
}) => {
- const { nullifiers, columns, showIcons = true, showCount = true } = props;
+ const {
+ nullifiers,
+ columns,
+ showIcons = true,
+ showCount = true,
+ showPagination = true,
+ } = props;
const rowsPerPageOptions = [5, 10, 20]; // Rows per page options
const [currentPage, setCurrentPage] = useState(1);
const [rowsPerPage, setRowsPerPage] = useState(5);
@@ -110,7 +117,7 @@ export const VerifiedTable = (props: {
- Verified humans
+ Recent verifications
{showCount ? (
-
+ {showPagination ? (
+
+ ) : null}
diff --git a/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/Actions/ActionId/page/index.tsx b/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/Actions/ActionId/page/index.tsx
index 21989e648e..8eddf16375 100644
--- a/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/Actions/ActionId/page/index.tsx
+++ b/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/Actions/ActionId/page/index.tsx
@@ -4,7 +4,7 @@ import { EngineType } from "@/lib/types";
import { ErrorPage } from "@/components/ErrorPage";
import { SkeletonTable } from "@/components/Skeletons";
import { TYPOGRAPHY, Typography } from "@/components/Typography";
-import { ActionStatsGraph } from "./ActionStatsGraph";
+import { WorldIdAnalyticsGraph } from "@/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/common/WorldIdAnalyticsGraph";
import { VerifiedTable } from "./VerifiedTable";
import { GetSingleActionAndNullifiersDocument } from "@/scenes/common/Teams/TeamId/Apps/AppId/Actions/ActionId/page/graphql/client/get-single-action.generated";
import { SizingWrapper } from "@/components/SizingWrapper";
@@ -25,6 +25,7 @@ export const ActionIdPage = (props: ActionIdPageProps) => {
});
const action = data?.action[0];
+ const environment = action?.app.is_staging ? "staging" : "production";
if (!loading && !action) {
return (
@@ -42,14 +43,22 @@ export const ActionIdPage = (props: ActionIdPageProps) => {
return (
-
+
{loading ? (
- Verified humans
+ Recent verifications
-
+
) : (
;
+
+export const PeriodSelector = (props: {
+ onPeriodChange: (period: AnalyticsPeriod) => void;
+ period: AnalyticsPeriod;
+}) => (
+
+);
diff --git a/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/common/Sparkline.tsx b/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/common/Sparkline.tsx
new file mode 100644
index 0000000000..95ce7ce90f
--- /dev/null
+++ b/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/common/Sparkline.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { useCallback, useState } from "react";
+
+const VIEW_W = 240;
+const VIEW_H = 56;
+const PAD = 3;
+
+export type SparklinePoint = {
+ count: string;
+ label?: string;
+};
+
+/**
+ * Uniform-stroke sparkline with the archived hover treatment: a dashed
+ * vertical rule plus a count/date popup. Interactive only when points carry
+ * labels; card previews pass bare counts and stay static.
+ */
+export const Sparkline = (props: {
+ points: SparklinePoint[];
+ ariaLabel: string;
+ className?: string;
+}) => {
+ const { points } = props;
+ const [hoverIndex, setHoverIndex] = useState(null);
+ const interactive = points.some((point) => point.label);
+
+ const handleMouseMove = useCallback(
+ (event: React.MouseEvent) => {
+ if (!interactive || points.length === 0) return;
+ const rect = event.currentTarget.getBoundingClientRect();
+ const fraction = rect.width
+ ? (event.clientX - rect.left) / rect.width
+ : 0;
+ const index = Math.round(fraction * (points.length - 1));
+ setHoverIndex(Math.max(0, Math.min(points.length - 1, index)));
+ },
+ [interactive, points.length],
+ );
+
+ const values = points.map((point) => Number(point.count));
+ const max = Math.max(...values, 1);
+ const stepX =
+ points.length > 1 ? (VIEW_W - PAD * 2) / (points.length - 1) : 0;
+ const yFor = (value: number) =>
+ VIEW_H - PAD - (value / max) * (VIEW_H - PAD * 2);
+ // Hover anchors: a lone point sits mid-width so its popup centers.
+ const coords = values.map((value, index) => ({
+ x: points.length > 1 ? PAD + index * stepX : VIEW_W / 2,
+ y: yFor(value),
+ }));
+ // A one-vertex polyline paints nothing, so a new entity (or an empty
+ // scope) would render a blank box instead of the contracted flat graph.
+ // Span those across the full width at their own height.
+ const line =
+ coords.length > 1
+ ? coords
+ : [
+ { x: PAD, y: yFor(values[0] ?? 0) },
+ { x: VIEW_W - PAD, y: yFor(values[0] ?? 0) },
+ ];
+ const polyline = line
+ .map((coord) => `${coord.x.toFixed(1)},${coord.y.toFixed(1)}`)
+ .join(" ");
+ const flatZero = points.every((point) => point.count === "0");
+
+ const hover = hoverIndex;
+ const hoverLeftPct = hover !== null ? (coords[hover].x / VIEW_W) * 100 : 0;
+ const tooltipShift =
+ hover === null
+ ? "-50%"
+ : hover === 0
+ ? "-10%"
+ : hover === points.length - 1
+ ? "-90%"
+ : "-50%";
+
+ return (
+
+
+
+ {interactive && hover !== null ? (
+ <>
+
+
+
+ {Number(points[hover].count).toLocaleString()}{" "}
+ {points[hover].count === "1" ? "verification" : "verifications"}
+
+
+ {points[hover].label}
+
+
+ >
+ ) : null}
+
+ );
+};
diff --git a/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/common/WorldIdAnalyticsGraph.tsx b/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/common/WorldIdAnalyticsGraph.tsx
new file mode 100644
index 0000000000..d8e4ec8241
--- /dev/null
+++ b/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/common/WorldIdAnalyticsGraph.tsx
@@ -0,0 +1,145 @@
+"use client";
+
+import {
+ bucketAllTimeSeries,
+ formatAnalyticsDate,
+ formatAnalyticsDateRange,
+ type AnalyticsPoint,
+} from "@/lib/world-id-analytics";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { PeriodSelector, type AnalyticsPeriod } from "./PeriodSelector";
+import { Sparkline } from "./Sparkline";
+
+type Metric = { count: string; series: AnalyticsPoint[] };
+type Response = {
+ period: AnalyticsPeriod;
+ app: Metric;
+ legacy_actions: Array;
+ actions: Array;
+};
+type Scope =
+ | { type: "app" }
+ | { type: "action"; source: "legacy" | "v4"; actionId: string };
+
+export function WorldIdAnalyticsGraph(props: {
+ appId: string;
+ environment: "staging" | "production";
+ scope: Scope;
+}) {
+ const [period, setPeriod] = useState("last_7_days");
+ const [cache, setCache] = useState<
+ Partial>
+ >({});
+ const [error, setError] = useState(false);
+ const mounted = useRef(true);
+ const periodRef = useRef(period);
+ periodRef.current = period;
+
+ const load = useCallback(
+ async (requestedPeriod: AnalyticsPeriod) => {
+ try {
+ const params = new URLSearchParams({
+ environment: props.environment,
+ period: requestedPeriod,
+ });
+ if (props.scope.type === "action") {
+ params.set("action_ids", props.scope.actionId);
+ }
+ const response = await fetch(
+ `/api/portal/apps/${props.appId}/world-id-analytics?${params}`,
+ );
+ if (!response.ok) throw new Error("analytics request failed");
+ const body = (await response.json()) as Response;
+ if (mounted.current) {
+ setCache((current) => ({ ...current, [requestedPeriod]: body }));
+ setError(false);
+ }
+ } catch {
+ if (mounted.current) setError(true);
+ }
+ },
+ [props.appId, props.environment, props.scope],
+ );
+
+ useEffect(() => {
+ mounted.current = true;
+ void load("last_7_days");
+ const refresh = () => {
+ if (document.visibilityState === "visible") void load(periodRef.current);
+ };
+ const timer = window.setInterval(refresh, 5 * 60 * 1000);
+ window.addEventListener("focus", refresh);
+ document.addEventListener("visibilitychange", refresh);
+ return () => {
+ mounted.current = false;
+ clearInterval(timer);
+ window.removeEventListener("focus", refresh);
+ document.removeEventListener("visibilitychange", refresh);
+ };
+ }, [load]);
+
+ const selectPeriod = (next: AnalyticsPeriod) => {
+ setPeriod(next);
+ if (!cache[next]) void load(next);
+ };
+ const response = cache[period] ?? cache.last_7_days;
+ const metric = useMemo(() => {
+ if (!response) return undefined;
+ if (props.scope.type === "app") return response.app;
+ const actionId = props.scope.actionId;
+ const list =
+ props.scope.source === "legacy"
+ ? response.legacy_actions
+ : response.actions;
+ return list.find((item) => item.id === actionId);
+ }, [props.scope, response]);
+
+ if (!metric && error) {
+ return Failed to load Unique Verifications
;
+ }
+ if (!metric) {
+ return (
+
+ );
+ }
+
+ const grouped = period === "all_time";
+ const buckets = grouped
+ ? bucketAllTimeSeries(metric.series)
+ : metric.series.map((point) => ({
+ start_date: point.date,
+ end_date: point.date,
+ count: point.count,
+ }));
+ const sparkPoints = buckets.map((bucket) => ({
+ count: bucket.count,
+ label: grouped
+ ? formatAnalyticsDateRange(bucket.start_date, bucket.end_date)
+ : formatAnalyticsDate(bucket.start_date),
+ }));
+
+ return (
+
+
+
+
+ Unique Verifications
+
+
+ {BigInt(metric.count).toLocaleString()}
+
+
+
+
+
+
+ );
+}
diff --git a/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/layout/index.tsx b/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/layout/index.tsx
index a4af7ea4ed..61439f2371 100644
--- a/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/layout/index.tsx
+++ b/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/layout/index.tsx
@@ -41,6 +41,7 @@ import {
WorldIdLayoutContext,
type WorldIdLayoutContextValue,
} from "./context";
+import { WorldIdAnalyticsGraph } from "../common/WorldIdAnalyticsGraph";
const BanBanner = () => {
const [, setIsOpened] = useAtom(banMessageDialogOpenedAtom);
@@ -376,6 +377,16 @@ export const WorldIdLayout = (props: {
{app?.is_banned ? : null}
+ {app ? (
+
+
+
+ ) : null}
+
{initialLoading && (
{
const { action } = props;
@@ -31,7 +42,10 @@ export const ActionCard = (props: {
app_id: props.appId,
action_id: action.id,
})}
- className={`${actionCardFrameClassName} transition-shadow hover:shadow-portal-card`}
+ // Keep the accessible name to the action identifier; the preview
+ // stats inside the link would otherwise concatenate into it.
+ aria-label={action.action}
+ className={`${worldIdActionCardFrameClassName} transition-shadow hover:shadow-portal-card`}
>
{action.action}
@@ -41,6 +55,21 @@ export const ActionCard = (props: {
) : null}
+ ({ count: point.count })) ?? []
+ }
+ ariaLabel={`Unique Verifications for ${action.action}`}
+ className="h-12 w-full text-portal-heading"
+ />
+
+
+ {BigInt(props.previewCount ?? "0").toLocaleString()}
+
+
+ Unique Verifications
+
+
);
};
diff --git a/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/page/ActionsGrid/index.tsx b/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/page/ActionsGrid/index.tsx
index 780b33e975..053e5340f2 100644
--- a/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/page/ActionsGrid/index.tsx
+++ b/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/page/ActionsGrid/index.tsx
@@ -52,6 +52,50 @@ export const ActionsGrid = (props: {
(page - 1) * ACTIONS_PER_PAGE,
page * ACTIONS_PER_PAGE,
);
+ const [previews, setPreviews] = useState<
+ Record<
+ string,
+ { count: string; series: Array<{ count: string; date: string }> }
+ >
+ >({});
+
+ useEffect(() => {
+ if (!pageActions.length) return;
+ const controller = new AbortController();
+ const params = new URLSearchParams({
+ environment: "production",
+ period: "last_7_days",
+ action_ids: pageActions.map((action) => action.id).join(","),
+ });
+ void fetch(`/api/portal/apps/${props.appId}/world-id-analytics?${params}`, {
+ signal: controller.signal,
+ })
+ .then((response) => {
+ if (!response.ok) throw new Error("analytics request failed");
+ return response.json();
+ })
+ .then(
+ (body: {
+ actions?: Array<{
+ id: string;
+ count: string;
+ series: Array<{ count: string; date: string }>;
+ }>;
+ }) => {
+ setPreviews(
+ Object.fromEntries(
+ (body.actions ?? []).map((item) => [
+ item.id,
+ { count: item.count, series: item.series },
+ ]),
+ ),
+ );
+ },
+ )
+ .catch(() => {});
+ return () => controller.abort();
+ }, [props.appId, page, props.search]);
+
// The create tile is its own empty state, so only explain a grid that would
// otherwise render nothing at all.
const emptyMessage =
@@ -89,6 +133,8 @@ export const ActionsGrid = (props: {
teamId={props.teamId}
appId={props.appId}
action={action}
+ previewCount={previews[action.id]?.count}
+ previewSeries={previews[action.id]?.series}
/>
))}
diff --git a/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldIdActions/ActionId/page/index.tsx b/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldIdActions/ActionId/page/index.tsx
index 721e75648a..e8dc67d931 100644
--- a/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldIdActions/ActionId/page/index.tsx
+++ b/web/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldIdActions/ActionId/page/index.tsx
@@ -17,6 +17,7 @@ import Skeleton from "react-loading-skeleton";
import { DeleteAction } from "./DeleteAction";
import { VerificationHistory } from "./VerificationHistory";
import { UpdateActionV4Form } from "../Settings/UpdateActionV4Form";
+import { WorldIdAnalyticsGraph } from "../../../WorldId/common/WorldIdAnalyticsGraph";
type Action = GetWorldIdActionDetailQuery["action_v4"][number];
@@ -114,6 +115,26 @@ export const WorldIdActionDetailPage = (props: {
)}
+ {action && (
+
+
+
+ )}
+ {!action && (
+
+
+
+ Unique Verifications
+
+
+
+
+ )}
+
{action && canModify ? (
diff --git a/web/scenes/common/Teams/TeamId/Apps/AppId/Actions/ActionId/page/graphql/client/get-single-action.generated.ts b/web/scenes/common/Teams/TeamId/Apps/AppId/Actions/ActionId/page/graphql/client/get-single-action.generated.ts
index f924c22064..64ea4648a1 100644
--- a/web/scenes/common/Teams/TeamId/Apps/AppId/Actions/ActionId/page/graphql/client/get-single-action.generated.ts
+++ b/web/scenes/common/Teams/TeamId/Apps/AppId/Actions/ActionId/page/graphql/client/get-single-action.generated.ts
@@ -23,6 +23,7 @@ export type GetSingleActionAndNullifiersQuery = {
__typename?: "app";
id: string;
engine: string;
+ is_staging: boolean;
rp_registration: Array<{ __typename?: "rp_registration"; rp_id: string }>;
};
}>;
@@ -141,6 +142,10 @@ export const GetSingleActionAndNullifiersDocument = {
kind: "Field",
name: { kind: "Name", value: "engine" },
},
+ {
+ kind: "Field",
+ name: { kind: "Name", value: "is_staging" },
+ },
{
kind: "Field",
name: { kind: "Name", value: "rp_registration" },
diff --git a/web/scenes/common/Teams/TeamId/Apps/AppId/Actions/ActionId/page/graphql/client/get-single-action.graphql b/web/scenes/common/Teams/TeamId/Apps/AppId/Actions/ActionId/page/graphql/client/get-single-action.graphql
index 867969a418..f9d642f260 100644
--- a/web/scenes/common/Teams/TeamId/Apps/AppId/Actions/ActionId/page/graphql/client/get-single-action.graphql
+++ b/web/scenes/common/Teams/TeamId/Apps/AppId/Actions/ActionId/page/graphql/client/get-single-action.graphql
@@ -11,6 +11,7 @@ query GetSingleActionAndNullifiers($action_id: String!) {
app {
id
engine
+ is_staging
rp_registration {
rp_id
}
diff --git a/web/tests/contracts/world-id-analytics-ui.ts b/web/tests/contracts/world-id-analytics-ui.ts
new file mode 100644
index 0000000000..e573870ecc
--- /dev/null
+++ b/web/tests/contracts/world-id-analytics-ui.ts
@@ -0,0 +1,8 @@
+/**
+ * Provisional shared-component seam.
+ *
+ * Product behavior tests import this adapter rather than freezing the final
+ * shared module location throughout the suite. [HITL] replaces this single
+ * export after the UI approach is approved.
+ */
+export { WorldIdAnalyticsGraph } from "@/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/common/WorldIdAnalyticsGraph";
diff --git a/web/tests/unit/pv3-world-id-action-card.test.tsx b/web/tests/unit/pv3-world-id-action-card.test.tsx
index 836df89141..7efe0ad760 100644
--- a/web/tests/unit/pv3-world-id-action-card.test.tsx
+++ b/web/tests/unit/pv3-world-id-action-card.test.tsx
@@ -12,6 +12,21 @@ global.ResizeObserver = class {
disconnect() {}
} as typeof ResizeObserver;
+// The grid fetches Last 7 Days previews for visible cards; stub the
+// endpoint so jsdom never attempts real network I/O (approach note §8).
+global.fetch = jest.fn(() =>
+ Promise.resolve({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ period: "last_7_days",
+ app: { count: "0", series: [] },
+ legacy_actions: [],
+ actions: [],
+ }),
+ } as Response),
+) as unknown as typeof fetch;
+
jest.mock(
"@/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldIdActions/page/CreateActionDialogV4",
() => ({
diff --git a/web/tests/unit/pv3-world-id-layout-freshness.test.tsx b/web/tests/unit/pv3-world-id-layout-freshness.test.tsx
index 91f610ae9a..312070c989 100644
--- a/web/tests/unit/pv3-world-id-layout-freshness.test.tsx
+++ b/web/tests/unit/pv3-world-id-layout-freshness.test.tsx
@@ -27,6 +27,15 @@ jest.mock(
() => ({ GetWorldIdOverviewDocument: { __mockDoc: "worldIdOverview" } }),
);
+// Keep the layout's single-query and revalidation contract isolated from the
+// independently tested analytics graph's fetch and refresh behavior.
+jest.mock(
+ "@/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/common/WorldIdAnalyticsGraph",
+ () => ({
+ WorldIdAnalyticsGraph: () => ,
+ }),
+);
+
jest.mock(
"@/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/LegacyActions/page",
() => ({
diff --git a/web/tests/unit/pv3-world-id-legacy-actions-page.test.tsx b/web/tests/unit/pv3-world-id-legacy-actions-page.test.tsx
index 72bb4aaa07..de69113070 100644
--- a/web/tests/unit/pv3-world-id-legacy-actions-page.test.tsx
+++ b/web/tests/unit/pv3-world-id-legacy-actions-page.test.tsx
@@ -100,6 +100,25 @@ beforeEach(() => {
// #endregion
describe("LegacyActionsPage", () => {
+ it("keeps loading skeletons at the legacy card height", () => {
+ useQueryMock.mockReturnValue({
+ data: undefined,
+ loading: true,
+ error: undefined,
+ });
+
+ const { container } = renderPage();
+ const skeletonFrames = Array.from(
+ container.querySelectorAll('div[aria-hidden="true"]'),
+ );
+
+ expect(skeletonFrames).toHaveLength(3);
+ for (const frame of skeletonFrames) {
+ expect(frame).toHaveClass("min-h-[144px]");
+ expect(frame).not.toHaveClass("min-h-[220px]");
+ }
+ });
+
it("renders the warning, matching search/grid UI, and read-only cards", async () => {
renderPage();
diff --git a/web/tests/unit/world-id-analytics-bucketing.test.ts b/web/tests/unit/world-id-analytics-bucketing.test.ts
new file mode 100644
index 0000000000..600ce8cbae
--- /dev/null
+++ b/web/tests/unit/world-id-analytics-bucketing.test.ts
@@ -0,0 +1,172 @@
+import {
+ bucketAllTimeSeries,
+ formatAnalyticsDate,
+ formatAnalyticsDateRange,
+ sumAnalyticsSeries,
+} from "@/lib/world-id-analytics";
+
+// #region Test Data
+type AnalyticsPoint = {
+ date: string;
+ count: string;
+};
+
+type AnalyticsBucket = {
+ start_date: string;
+ end_date: string;
+ count: string;
+};
+
+const point = (day: number, count = day): AnalyticsPoint => ({
+ date: `2026-01-${day.toString().padStart(2, "0")}`,
+ count: count.toString(),
+});
+
+const days = (length: number) =>
+ Array.from({ length }, (_, index) => point(index + 1));
+
+const bucketSize = (bucket: { start_date: string; end_date: string }) => {
+ const start = Date.parse(`${bucket.start_date}T00:00:00.000Z`);
+ const end = Date.parse(`${bucket.end_date}T00:00:00.000Z`);
+ return Math.round((end - start) / 86_400_000) + 1;
+};
+// #endregion
+
+// #region Selected-period totals
+describe("World ID analytics series [selected-period total]", () => {
+ it("sums integer-safe point strings without converting through Number", () => {
+ expect(
+ sumAnalyticsSeries([
+ { date: "2026-01-01", count: "9007199254740993" },
+ { date: "2026-01-02", count: "9" },
+ ]),
+ ).toBe("9007199254741002");
+ });
+
+ it("returns zero for an empty series", () => {
+ expect(sumAnalyticsSeries([])).toBe("0");
+ });
+});
+// #endregion
+
+// #region All Time bucket boundaries
+describe("World ID analytics series [All Time grouping]", () => {
+ it.each([0, 1, 6, 7])(
+ "keeps a natural %i-point series ungrouped",
+ (length) => {
+ const input = days(length);
+ const buckets = bucketAllTimeSeries(input);
+
+ expect(buckets).toHaveLength(length);
+ expect(
+ buckets.map((bucket: AnalyticsBucket) => ({
+ start_date: bucket.start_date,
+ end_date: bucket.end_date,
+ count: bucket.count,
+ })),
+ ).toEqual(
+ input.map((item) => ({
+ start_date: item.date,
+ end_date: item.date,
+ count: item.count,
+ })),
+ );
+ },
+ );
+
+ it("groups eight days into seven consecutive near-equal ranges", () => {
+ const buckets = bucketAllTimeSeries(days(8));
+
+ expect(buckets).toHaveLength(7);
+ expect(buckets[0]).toEqual({
+ start_date: "2026-01-01",
+ end_date: "2026-01-02",
+ count: "3",
+ });
+ expect(buckets.at(-1)).toEqual({
+ start_date: "2026-01-08",
+ end_date: "2026-01-08",
+ count: "8",
+ });
+ expect(
+ buckets.map((bucket: AnalyticsBucket) => bucketSize(bucket)),
+ ).toEqual([2, 1, 1, 1, 1, 1, 1]);
+ });
+
+ it("uses at most seven near-equal buckets for a long daily history", () => {
+ const input = days(28);
+ const buckets = bucketAllTimeSeries(input);
+ const sizes = buckets.map((bucket: AnalyticsBucket) => bucketSize(bucket));
+
+ expect(buckets).toHaveLength(7);
+ expect(Math.max(...sizes) - Math.min(...sizes)).toBeLessThanOrEqual(1);
+ expect(buckets[0].start_date).toBe(input[0].date);
+ expect(buckets.at(-1)?.end_date).toBe(input.at(-1)?.date);
+ });
+
+ it("preserves order, contiguity, and the exact total for a non-even history", () => {
+ const input = days(23);
+ const buckets = bucketAllTimeSeries(input);
+
+ for (let index = 1; index < buckets.length; index += 1) {
+ const previousEnd = new Date(
+ `${buckets[index - 1].end_date}T00:00:00.000Z`,
+ );
+ previousEnd.setUTCDate(previousEnd.getUTCDate() + 1);
+ expect(buckets[index].start_date).toBe(
+ previousEnd.toISOString().slice(0, 10),
+ );
+ }
+ expect(sumAnalyticsSeries(input)).toBe(
+ buckets
+ .reduce(
+ (total: bigint, bucket: AnalyticsBucket) =>
+ total + BigInt(bucket.count),
+ 0n,
+ )
+ .toString(),
+ );
+ });
+
+ it("sums each range instead of averaging or accumulating across ranges", () => {
+ const buckets = bucketAllTimeSeries(
+ Array.from({ length: 14 }, (_, index) => point(index + 1, 2)),
+ );
+
+ expect(buckets.map((bucket: AnalyticsBucket) => bucket.count)).toEqual([
+ "4",
+ "4",
+ "4",
+ "4",
+ "4",
+ "4",
+ "4",
+ ]);
+ });
+});
+// #endregion
+
+// #region UTC labels
+describe("World ID analytics series [UTC labels]", () => {
+ it("formats a date-only value as the same UTC calendar date", () => {
+ expect(formatAnalyticsDate("2026-01-01")).toBe("Jan 1, 2026");
+ });
+
+ it("formats grouped tooltips with both UTC range boundaries", () => {
+ expect(formatAnalyticsDateRange("2025-12-31", "2026-01-02")).toBe(
+ "Dec 31, 2025 – Jan 2, 2026",
+ );
+ });
+
+ it("does not shift a date when the runtime timezone is west of UTC", () => {
+ const previousTimezone = process.env.TZ;
+ process.env.TZ = "America/Los_Angeles";
+
+ try {
+ expect(formatAnalyticsDate("2026-07-30")).toBe("Jul 30, 2026");
+ } finally {
+ process.env.TZ = previousTimezone;
+ }
+ });
+});
+// #endregion
diff --git a/web/tests/unit/world-id-analytics-graph.test.tsx b/web/tests/unit/world-id-analytics-graph.test.tsx
new file mode 100644
index 0000000000..2dd5a947f7
--- /dev/null
+++ b/web/tests/unit/world-id-analytics-graph.test.tsx
@@ -0,0 +1,514 @@
+/** @jest-environment jsdom */
+import "@testing-library/jest-dom";
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { makeAnalyticsResponse } from "../contracts/world-id-analytics-endpoint";
+import { WorldIdAnalyticsGraph } from "../contracts/world-id-analytics-ui";
+
+// #region Mocks
+const fetchMock = jest.fn();
+global.fetch = fetchMock as unknown as typeof fetch;
+
+class ResizeObserverMock {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+global.ResizeObserver = ResizeObserverMock;
+// #endregion
+
+// #region Test Data
+const appId = "app_00000000000000000000000000000001";
+const actionId = "action_v4_0000000000000000000000000001";
+
+const responseBody = (input?: {
+ period?: "last_7_days" | "all_time";
+ appCount?: string;
+ appPoints?: Array<{ date: string; count: string }>;
+ actionCount?: string;
+ actionPoints?: Array<{ date: string; count: string }>;
+}) =>
+ makeAnalyticsResponse({
+ period: input?.period ?? "last_7_days",
+ app: {
+ count: input?.appCount ?? "3",
+ series: input?.appPoints ?? [
+ { date: "2026-07-28", count: "0" },
+ { date: "2026-07-29", count: "1" },
+ { date: "2026-07-30", count: "2" },
+ ],
+ },
+ legacyActions: [],
+ actions: [
+ {
+ id: actionId,
+ count: input?.actionCount ?? "3",
+ series: input?.actionPoints ?? [
+ { date: "2026-07-28", count: "0" },
+ { date: "2026-07-29", count: "1" },
+ { date: "2026-07-30", count: "2" },
+ ],
+ },
+ ],
+ });
+
+const ok = (body = responseBody()) =>
+ Promise.resolve({
+ ok: true,
+ status: 200,
+ json: async () => body,
+ } as Response);
+
+const deferredResponse = () => {
+ let resolve!: (response: Response) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+};
+
+const renderAppGraph = () =>
+ render(
+ ,
+ );
+
+const renderActionGraph = () =>
+ render(
+ ,
+ );
+
+const choosePeriod = async (label: "All Time" | "Last 7 Days") => {
+ fireEvent.click(
+ screen.getByRole("button", { name: "Unique Verifications period" }),
+ );
+ fireEvent.click(await screen.findByRole("option", { name: label }));
+};
+
+const requested = (callIndex = 0) => {
+ const input = fetchMock.mock.calls[callIndex][0] as string | URL | Request;
+ const raw =
+ input instanceof Request
+ ? input.url
+ : input instanceof URL
+ ? input.toString()
+ : input;
+ return new URL(raw, "http://localhost");
+};
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date("2026-07-30T12:00:00.000Z"));
+ fetchMock.mockImplementation(() => ok());
+ Object.defineProperty(document, "visibilityState", {
+ configurable: true,
+ value: "visible",
+ });
+});
+
+afterEach(() => {
+ jest.useRealTimers();
+});
+// #endregion
+
+// #region Initial period and display
+describe("WorldIdAnalyticsGraph [initial period]", () => {
+ it("renders a structural loading state before the first response", () => {
+ const pending = deferredResponse();
+ fetchMock.mockImplementationOnce(() => pending.promise);
+
+ renderAppGraph();
+
+ expect(
+ screen.getByRole("status", {
+ name: "Loading Unique Verifications",
+ }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText(/stale|delayed|watermark/i),
+ ).not.toBeInTheDocument();
+ });
+
+ it("requests Last 7 Days by default and displays its point sum", async () => {
+ renderAppGraph();
+
+ expect(await screen.findByText("3")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Unique Verifications period" }),
+ ).toHaveTextContent("Last 7 Days");
+ expect(requested().pathname).toBe(
+ `/api/portal/apps/${appId}/world-id-analytics`,
+ );
+ expect(requested().searchParams.get("environment")).toBe("production");
+ expect(requested().searchParams.get("period")).toBe("last_7_days");
+ expect(requested().searchParams.has("action_ids")).toBe(false);
+ expect(
+ screen.getByRole("img", { name: "Unique Verifications" }),
+ ).toBeInTheDocument();
+ });
+
+ it("selects a requested source-specific action block", async () => {
+ fetchMock.mockImplementation(() =>
+ ok(
+ responseBody({
+ appCount: "99",
+ actionCount: "4",
+ actionPoints: [
+ { date: "2026-07-29", count: "1" },
+ { date: "2026-07-30", count: "3" },
+ ],
+ }),
+ ),
+ );
+
+ renderActionGraph();
+
+ expect(await screen.findByText("4")).toBeInTheDocument();
+ expect(screen.queryByText("99")).not.toBeInTheDocument();
+ expect(requested().searchParams.get("action_ids")).toBe(actionId);
+ });
+
+ it("renders zero as a flat graph instead of hiding the chart", async () => {
+ fetchMock.mockImplementation(() =>
+ ok(
+ responseBody({
+ appCount: "0",
+ appPoints: [
+ { date: "2026-07-28", count: "0" },
+ { date: "2026-07-29", count: "0" },
+ { date: "2026-07-30", count: "0" },
+ ],
+ }),
+ ),
+ );
+
+ renderAppGraph();
+
+ expect(await screen.findByText("0")).toBeInTheDocument();
+ const graph = screen.getByRole("img", {
+ name: "Unique Verifications",
+ });
+ expect(graph).toBeVisible();
+ expect(graph.querySelector("polyline")).toHaveAttribute(
+ "data-flat-zero",
+ "true",
+ );
+ });
+
+ it("still draws a line when a new entity has a single point", async () => {
+ fetchMock.mockImplementation(() =>
+ ok(
+ responseBody({
+ appCount: "0",
+ appPoints: [{ date: "2026-07-30", count: "0" }],
+ }),
+ ),
+ );
+
+ renderAppGraph();
+
+ expect(await screen.findByText("0")).toBeInTheDocument();
+ const polyline = screen
+ .getByRole("img", { name: "Unique Verifications" })
+ .querySelector("polyline");
+ // An entity created today is truncated to one point, and a one-vertex
+ // polyline paints nothing — the flat graph must still be drawable.
+ expect(
+ polyline?.getAttribute("points")?.trim().split(/\s+/).length,
+ ).toBeGreaterThanOrEqual(2);
+ expect(polyline).toHaveAttribute("data-flat-zero", "true");
+ });
+
+ it("does not persist period selection in the page URL", async () => {
+ window.history.replaceState({}, "", "/world-id-4-0?tab=actions");
+ fetchMock
+ .mockImplementationOnce(() => ok())
+ .mockImplementationOnce(() =>
+ ok(responseBody({ period: "all_time", appCount: "8" })),
+ );
+ renderAppGraph();
+ await screen.findByText("3");
+
+ await choosePeriod("All Time");
+ expect(await screen.findByText("8")).toBeInTheDocument();
+
+ expect(window.location.search).toBe("?tab=actions");
+ });
+
+ it("uses the portal error language for an initial hard failure", async () => {
+ fetchMock.mockRejectedValueOnce(new Error("offline"));
+
+ renderAppGraph();
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Failed to load Unique Verifications",
+ );
+ });
+
+ it("treats an initial non-2xx response as a hard failure", async () => {
+ fetchMock.mockResolvedValueOnce({
+ ok: false,
+ status: 503,
+ json: async () => ({ error: "unavailable" }),
+ } as Response);
+
+ renderAppGraph();
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Failed to load Unique Verifications",
+ );
+ expect(
+ screen.queryByRole("img", { name: "Unique Verifications" }),
+ ).not.toBeInTheDocument();
+ });
+});
+// #endregion
+
+// #region All Time lazy loading and cache
+describe("WorldIdAnalyticsGraph [All Time loading]", () => {
+ it("lazy-loads All Time once and reuses it for the mounted page", async () => {
+ fetchMock
+ .mockImplementationOnce(() => ok())
+ .mockImplementationOnce(() =>
+ ok(
+ responseBody({
+ period: "all_time",
+ appCount: "10",
+ appPoints: [
+ { date: "2026-07-20", count: "4" },
+ { date: "2026-07-30", count: "6" },
+ ],
+ }),
+ ),
+ );
+ renderAppGraph();
+ await screen.findByText("3");
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ await choosePeriod("All Time");
+ expect(await screen.findByText("10")).toBeInTheDocument();
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ expect(requested(1).searchParams.get("period")).toBe("all_time");
+
+ await choosePeriod("Last 7 Days");
+ expect(screen.getByText("3")).toBeInTheDocument();
+ await choosePeriod("All Time");
+ expect(screen.getByText("10")).toBeInTheDocument();
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it("keeps Last 7 Days visible while the first All Time request is pending", async () => {
+ const pending = deferredResponse();
+ fetchMock
+ .mockImplementationOnce(() => ok())
+ .mockImplementationOnce(() => pending.promise);
+ renderAppGraph();
+ await screen.findByText("3");
+
+ await choosePeriod("All Time");
+
+ expect(screen.getByText("3")).toBeInTheDocument();
+ expect(
+ screen.getByRole("img", { name: "Unique Verifications" }),
+ ).toBeVisible();
+
+ await act(async () => {
+ pending.resolve(
+ await ok(responseBody({ period: "all_time", appCount: "10" })),
+ );
+ });
+ expect(await screen.findByText("10")).toBeInTheDocument();
+ });
+
+ it("keeps cached All Time points after an All Time refresh error", async () => {
+ fetchMock
+ .mockImplementationOnce(() => ok())
+ .mockImplementationOnce(() =>
+ ok(responseBody({ period: "all_time", appCount: "10" })),
+ )
+ .mockRejectedValueOnce(new Error("offline"));
+ renderAppGraph();
+ await screen.findByText("3");
+
+ await choosePeriod("All Time");
+ await screen.findByText("10");
+
+ await act(async () => {
+ jest.advanceTimersByTime(5 * 60 * 1000);
+ });
+
+ expect(screen.getByText("10")).toBeInTheDocument();
+ expect(
+ screen.getByRole("img", { name: "Unique Verifications" }),
+ ).toBeVisible();
+ });
+});
+// #endregion
+
+// #region Refresh behavior
+describe("WorldIdAnalyticsGraph [refresh]", () => {
+ it("refreshes every five minutes while visible and immediately on focus", async () => {
+ renderAppGraph();
+ await screen.findByText("3");
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ jest.advanceTimersByTime(5 * 60 * 1000);
+ });
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
+
+ await act(async () => {
+ window.dispatchEvent(new Event("focus"));
+ });
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3));
+ });
+
+ it("does not run the interval refresh while the page is hidden", async () => {
+ renderAppGraph();
+ await screen.findByText("3");
+ Object.defineProperty(document, "visibilityState", {
+ configurable: true,
+ value: "hidden",
+ });
+
+ await act(async () => {
+ jest.advanceTimersByTime(10 * 60 * 1000);
+ });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("refreshes immediately when a hidden page becomes visible again", async () => {
+ renderAppGraph();
+ await screen.findByText("3");
+ Object.defineProperty(document, "visibilityState", {
+ configurable: true,
+ value: "hidden",
+ });
+
+ await act(async () => {
+ jest.advanceTimersByTime(10 * 60 * 1000);
+ });
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ Object.defineProperty(document, "visibilityState", {
+ configurable: true,
+ value: "visible",
+ });
+ await act(async () => {
+ document.dispatchEvent(new Event("visibilitychange"));
+ });
+
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
+ });
+
+ it("keeps current points visible while a background refresh is pending", async () => {
+ const pending = deferredResponse();
+ fetchMock
+ .mockImplementationOnce(() => ok())
+ .mockImplementationOnce(() => pending.promise);
+ renderAppGraph();
+ await screen.findByText("3");
+
+ await act(async () => {
+ jest.advanceTimersByTime(5 * 60 * 1000);
+ });
+
+ expect(screen.getByText("3")).toBeInTheDocument();
+ expect(
+ screen.getByRole("img", { name: "Unique Verifications" }),
+ ).toBeVisible();
+
+ await act(async () => {
+ pending.resolve(await ok(responseBody({ appCount: "6" })));
+ });
+ expect(await screen.findByText("6")).toBeInTheDocument();
+ });
+
+ it("keeps the last successful graph after a refresh error", async () => {
+ fetchMock
+ .mockImplementationOnce(() => ok())
+ .mockRejectedValueOnce(new Error("offline"));
+ renderAppGraph();
+ await screen.findByText("3");
+
+ await act(async () => {
+ jest.advanceTimersByTime(5 * 60 * 1000);
+ });
+
+ expect(screen.getByText("3")).toBeInTheDocument();
+ expect(
+ screen.getByRole("img", { name: "Unique Verifications" }),
+ ).toBeVisible();
+ expect(
+ screen.queryByText(/stale|delayed|watermark/i),
+ ).not.toBeInTheDocument();
+ });
+});
+// #endregion
+
+// #region Grouped UTC tooltips
+describe("WorldIdAnalyticsGraph [grouped UTC tooltip]", () => {
+ it("renders both UTC boundaries for the first and last grouped ranges", async () => {
+ fetchMock
+ .mockImplementationOnce(() => ok())
+ .mockImplementationOnce(() =>
+ ok(
+ responseBody({
+ period: "all_time",
+ appCount: "36",
+ appPoints: Array.from({ length: 8 }, (_, index) => ({
+ date: `2026-01-${(index + 1).toString().padStart(2, "0")}`,
+ count: (index + 1).toString(),
+ })),
+ }),
+ ),
+ );
+ renderAppGraph();
+ await screen.findByText("3");
+ await choosePeriod("All Time");
+ await screen.findByText("36");
+
+ const graph = screen.getByRole("img", { name: "Unique Verifications" });
+ Object.defineProperty(graph, "getBoundingClientRect", {
+ configurable: true,
+ value: () => ({
+ bottom: 100,
+ height: 100,
+ left: 0,
+ right: 700,
+ top: 0,
+ width: 700,
+ x: 0,
+ y: 0,
+ toJSON: () => ({}),
+ }),
+ });
+
+ fireEvent.mouseMove(graph, { clientX: 1, clientY: 50 });
+ expect(
+ await screen.findByText(/Jan 1, 2026.*Jan 2, 2026/),
+ ).toBeInTheDocument();
+
+ fireEvent.mouseMove(graph, { clientX: 699, clientY: 50 });
+ expect(
+ await screen.findByText(/Jan 8, 2026.*Jan 8, 2026/),
+ ).toBeInTheDocument();
+ });
+});
+// #endregion
diff --git a/web/tests/unit/world-id-analytics-surfaces.test.tsx b/web/tests/unit/world-id-analytics-surfaces.test.tsx
new file mode 100644
index 0000000000..b816c52351
--- /dev/null
+++ b/web/tests/unit/world-id-analytics-surfaces.test.tsx
@@ -0,0 +1,483 @@
+/** @jest-environment jsdom */
+import "@testing-library/jest-dom";
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from "@testing-library/react";
+import { print } from "graphql";
+import React, { Suspense } from "react";
+import { WorldIdLayout } from "@/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/layout";
+import { ActionsGrid } from "@/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/page/ActionsGrid";
+import { WorldIdPage } from "@/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/page";
+import { WorldIdActionDetailPage } from "@/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldIdActions/ActionId/page";
+import { ActionIdPage as PortalV3LegacyActionPage } from "@/scenes/PortalV3/Teams/TeamId/Apps/AppId/Actions/ActionId/page";
+import { ActionIdPage as PortalLegacyActionPage } from "@/scenes/Portal/Teams/TeamId/Apps/AppId/Actions/ActionId/page";
+import { makeAnalyticsResponse } from "../contracts/world-id-analytics-endpoint";
+
+// #region Mocks
+const useQueryMock = jest.fn();
+const refetch = jest.fn();
+const fetchMock = jest.fn();
+
+jest.mock("@apollo/client/react", () => ({
+ useQuery: (...args: unknown[]) => useQueryMock(...args),
+}));
+
+let searchParams = new URLSearchParams();
+jest.mock("next/navigation", () => ({
+ useRouter: () => ({
+ replace: jest.fn(),
+ push: jest.fn(),
+ refresh: jest.fn(),
+ }),
+ usePathname: () => "/teams/team_1/apps/app_1/world-id-4-0",
+ useSearchParams: () => searchParams,
+ useParams: () => ({ teamId: "team_1", appId: "app_1" }),
+}));
+
+jest.mock(
+ "@/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldIdActions/page/CreateActionDialogV4",
+ () => ({
+ CreateActionDialogV4: () => ,
+ }),
+);
+
+jest.mock(
+ "@/scenes/PortalV3/Teams/TeamId/Apps/AppId/Configuration/Danger/DangerZoneSection",
+ () => ({
+ DangerZoneSection: () => ,
+ }),
+);
+
+jest.mock(
+ "@/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/layout/RpSummary",
+ () => ({
+ RpSummary: () => ,
+ }),
+);
+
+jest.mock(
+ "@/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldId/layout/RegisterRpEmptyState",
+ () => ({
+ RegisterRpEmptyState: () => ,
+ }),
+);
+
+jest.mock(
+ "@/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldIdActions/ActionId/Settings/UpdateActionV4Form",
+ () => ({
+ UpdateActionV4Form: () => ,
+ }),
+);
+
+jest.mock(
+ "@/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldIdActions/ActionId/page/DeleteAction",
+ () => ({
+ DeleteAction: () => ,
+ }),
+);
+
+jest.mock(
+ "@/scenes/PortalV3/Teams/TeamId/Apps/AppId/WorldIdActions/ActionId/page/VerificationHistory",
+ () => ({
+ VerificationHistory: () => ,
+ }),
+);
+
+global.fetch = fetchMock as unknown as typeof fetch;
+global.ResizeObserver = class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+} as typeof ResizeObserver;
+// #endregion
+
+// #region Test Data
+const appId = "app_00000000000000000000000000000001";
+const v3ActionId = "action_000000000000000000000000000001";
+const v4ActionId = "action_v4_0000000000000000000000000001";
+
+const makePointSeries = (count = "5") => [
+ { date: "2026-07-28", count: "0" },
+ { date: "2026-07-29", count: "2" },
+ { date: "2026-07-30", count },
+];
+
+const analyticsResponseFor = (requestedActionIds: string[] = []) =>
+ makeAnalyticsResponse({
+ period: "last_7_days",
+ app: {
+ count: "7",
+ series: makePointSeries("5"),
+ },
+ legacyActions: requestedActionIds
+ .filter((id) => id.startsWith("action_") && !id.startsWith("action_v4_"))
+ .map((id) => ({
+ id,
+ count: "4",
+ series: makePointSeries("2"),
+ })),
+ actions: requestedActionIds
+ .filter((id) => id.startsWith("action_v4_"))
+ .map((id) => ({
+ id,
+ count: "5",
+ series: makePointSeries("3"),
+ })),
+ });
+
+const requestUrl = (input: string | URL | Request) => {
+ if (input instanceof Request) return new URL(input.url);
+ return new URL(input.toString(), "http://localhost");
+};
+
+const installAnalyticsFetch = () => {
+ fetchMock.mockImplementation((input: string | URL | Request) => {
+ const url = requestUrl(input);
+ const ids = (url.searchParams.get("action_ids") ?? "")
+ .split(",")
+ .filter(Boolean);
+ return Promise.resolve({
+ ok: true,
+ status: 200,
+ json: async () => analyticsResponseFor(ids),
+ } as Response);
+ });
+};
+
+const overviewData = (actions = 2) => ({
+ app: [
+ {
+ id: appId,
+ is_banned: false,
+ is_staging: false,
+ rp_registration: [
+ {
+ rp_id: "rp_0000000000000001",
+ status: "registered",
+ staging_status: null,
+ mode: "managed",
+ created_at: "2026-07-20T00:00:00.000Z",
+ },
+ ],
+ },
+ ],
+ action: [{ id: v3ActionId }],
+ action_v4: Array.from({ length: actions }, (_, index) => ({
+ id: `action_v4_${(index + 1).toString().padStart(32, "0")}`,
+ action: `action-${index + 1}`,
+ description: "",
+ })),
+});
+
+const renderWorldIdApp = () =>
+ render(
+
+
+ ,
+ );
+
+const v4DetailData = {
+ action_v4: [
+ {
+ id: v4ActionId,
+ action: "vote",
+ description: "Vote once",
+ created_at: "2026-07-20T00:00:00.000Z",
+ nullifiers_aggregate: { aggregate: { count: 999 } },
+ nullifiers: Array.from({ length: 6 }, (_, index) => ({
+ id: `nullifier_v4_${index + 1}`,
+ action_v4_id: v4ActionId,
+ created_at: "2026-07-30T11:00:00.000Z",
+ nullifier: `0x1234567890abcdef1234567890abc${String(index + 1).padStart(3, "0")}`,
+ })),
+ },
+ ],
+};
+
+const legacyDetailData = (isStaging = false) => ({
+ action: [
+ {
+ id: v3ActionId,
+ app: { engine: "cloud", is_staging: isStaging },
+ nullifiers: [
+ {
+ id: "nil_1",
+ updated_at: "2026-07-30T11:00:00.000Z",
+ nullifier_hash: "1234567890abcdef1234567890abcdef",
+ uses: 8,
+ },
+ ],
+ },
+ ],
+});
+
+const fulfilledParams = {
+ then(resolve: (value: Record) => void) {
+ resolve({
+ teamId: "team_1",
+ appId,
+ actionId: v3ActionId,
+ });
+ },
+} as unknown as Promise>;
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ searchParams = new URLSearchParams();
+ refetch.mockResolvedValue({});
+ installAnalyticsFetch();
+ Object.defineProperty(document, "visibilityState", {
+ configurable: true,
+ value: "visible",
+ });
+});
+// #endregion
+
+// #region App hero
+describe("World ID app page [combined analytics hero]", () => {
+ it("renders one combined metric for the whole app", async () => {
+ useQueryMock.mockReturnValue({
+ data: overviewData(),
+ loading: false,
+ error: undefined,
+ refetch,
+ });
+
+ renderWorldIdApp();
+
+ expect(
+ await screen.findByRole("heading", { name: "Unique Verifications" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("7")).toBeInTheDocument();
+
+ const appRequests = fetchMock.mock.calls
+ .map((call) => requestUrl(call[0]))
+ .filter((url) => !url.searchParams.has("action_ids"));
+ expect(appRequests).toHaveLength(1);
+ expect(appRequests[0].searchParams.get("period")).toBe("last_7_days");
+ expect(appRequests[0].searchParams.get("environment")).toBe("production");
+ });
+
+ it("keeps the hero on production even when the app is staging", async () => {
+ useQueryMock.mockReturnValue({
+ data: {
+ ...overviewData(),
+ app: [{ ...overviewData().app[0], is_staging: true }],
+ },
+ loading: false,
+ error: undefined,
+ refetch,
+ });
+
+ renderWorldIdApp();
+
+ await screen.findByRole("heading", { name: "Unique Verifications" });
+ const appRequest = fetchMock.mock.calls
+ .map((call) => requestUrl(call[0]))
+ .find((url) => !url.searchParams.has("action_ids"));
+ expect(appRequest?.searchParams.get("environment")).toBe("production");
+ });
+
+ it("keeps the app total independent of card search and pagination requests", async () => {
+ useQueryMock.mockReturnValue({
+ data: overviewData(13),
+ loading: false,
+ error: undefined,
+ refetch,
+ });
+ renderWorldIdApp();
+ expect(await screen.findByText("7")).toBeInTheDocument();
+
+ fireEvent.change(screen.getByPlaceholderText(/search/i), {
+ target: { value: "action-13" },
+ });
+
+ expect(screen.getByText("7")).toBeInTheDocument();
+ const appRequest = fetchMock.mock.calls
+ .map((call) => requestUrl(call[0]))
+ .find((url) => !url.searchParams.has("action_ids"));
+ expect(appRequest?.searchParams.has("action_ids")).toBe(false);
+ });
+});
+// #endregion
+
+// #region V4 card previews
+describe("World ID action grid [visible previews]", () => {
+ it("requests Last 7 Days previews for only the twelve visible action ids", async () => {
+ const actions = Array.from({ length: 13 }, (_, index) => ({
+ id: `action_v4_${(index + 1).toString().padStart(32, "0")}`,
+ action: `action-${index + 1}`,
+ description: "",
+ }));
+ render(
+ ,
+ );
+
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled());
+ const firstPageRequestedIds = new Set(
+ fetchMock.mock.calls.flatMap((call) =>
+ (requestUrl(call[0]).searchParams.get("action_ids") ?? "")
+ .split(",")
+ .filter(Boolean),
+ ),
+ );
+
+ expect(firstPageRequestedIds).toEqual(
+ new Set(actions.slice(0, 12).map((action) => action.id)),
+ );
+ expect(firstPageRequestedIds.has(actions[12].id)).toBe(false);
+ for (const call of fetchMock.mock.calls) {
+ const url = requestUrl(call[0]);
+ expect(url.searchParams.get("period")).toBe("last_7_days");
+ expect(
+ (url.searchParams.get("action_ids") ?? "").split(",").filter(Boolean)
+ .length,
+ ).toBeLessThanOrEqual(12);
+ }
+
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
+ await waitFor(() =>
+ expect(
+ fetchMock.mock.calls.some((call) =>
+ (requestUrl(call[0]).searchParams.get("action_ids") ?? "")
+ .split(",")
+ .includes(actions[12].id),
+ ),
+ ).toBe(true),
+ );
+ expect(
+ screen.getByText("Unique Verifications", {
+ selector: "span",
+ }),
+ ).toBeInTheDocument();
+ });
+});
+// #endregion
+
+// #region V4 action detail
+describe("World ID v4 action detail [single aggregate]", () => {
+ it("uses the graph number as the sole aggregate beside the history feed", async () => {
+ useQueryMock.mockReturnValue({
+ data: v4DetailData,
+ loading: false,
+ error: undefined,
+ refetch,
+ });
+
+ render(
+ ,
+ );
+
+ expect(
+ await screen.findByRole("heading", { name: "Unique Verifications" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("5", { selector: "div" })).toBeInTheDocument();
+ // The raw nullifier aggregate must never render as the headline stat;
+ // the verification feed itself is VerificationHistory's own coverage.
+ expect(screen.queryByText("999")).not.toBeInTheDocument();
+ expect(screen.getByTestId("v4-history")).toBeInTheDocument();
+ expect(screen.queryByText(/Verified humans/i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/^Human$/i)).not.toBeInTheDocument();
+ });
+});
+// #endregion
+
+// #region Active legacy v3 action details
+describe.each([
+ ["PortalV3", PortalV3LegacyActionPage],
+ ["Portal", PortalLegacyActionPage],
+])(
+ "%s legacy action detail [analytics replacement]",
+ (_name, LegacyActionPage) => {
+ it("replaces ActionStatsGraph while preserving the bounded verification feed", async () => {
+ useQueryMock.mockReturnValue({
+ data: legacyDetailData(),
+ loading: false,
+ error: undefined,
+ });
+
+ await act(async () => {
+ render(
+ loading }>
+