diff --git a/web/lib/world-id-analytics.ts b/web/lib/world-id-analytics.ts new file mode 100644 index 0000000000..fc4f0a1f3b --- /dev/null +++ b/web/lib/world-id-analytics.ts @@ -0,0 +1,48 @@ +export type AnalyticsPoint = { date: string; count: string }; +export type AnalyticsBucket = { + start_date: string; + end_date: string; + count: string; +}; + +export const sumAnalyticsSeries = (series: AnalyticsPoint[]) => + series.reduce((sum, point) => sum + BigInt(point.count), 0n).toString(); + +export function bucketAllTimeSeries( + series: AnalyticsPoint[], +): AnalyticsBucket[] { + if (series.length <= 7) { + return series.map((point) => ({ + start_date: point.date, + end_date: point.date, + count: point.count, + })); + } + const base = Math.floor(series.length / 7); + const remainder = series.length % 7; + let offset = 0; + return Array.from({ length: 7 }, (_, index) => { + const size = base + (index < remainder ? 1 : 0); + const slice = series.slice(offset, offset + size); + offset += size; + return { + start_date: slice[0].date, + end_date: slice.at(-1)!.date, + count: slice + .reduce((sum, point) => sum + BigInt(point.count), 0n) + .toString(), + }; + }); +} + +const formatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + timeZone: "UTC", +}); + +export const formatAnalyticsDate = (date: string) => + formatter.format(new Date(`${date}T00:00:00.000Z`)); +export const formatAnalyticsDateRange = (start: string, end: string) => + `${formatAnalyticsDate(start)} – ${formatAnalyticsDate(end)}`; diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/Actions/ActionId/page/VerifiedTable/VerifiedRow/index.tsx b/web/scenes/Portal/Teams/TeamId/Apps/AppId/Actions/ActionId/page/VerifiedTable/VerifiedRow/index.tsx index d08811c029..42cd440c51 100644 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/Actions/ActionId/page/VerifiedTable/VerifiedRow/index.tsx +++ b/web/scenes/Portal/Teams/TeamId/Apps/AppId/Actions/ActionId/page/VerifiedTable/VerifiedRow/index.tsx @@ -26,7 +26,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": @@ -108,7 +108,7 @@ export const VerifiedTable = (props: {
- Verified humans + Recent verifications { }); const action = data?.action[0]; + const environment = action?.app.is_staging ? "staging" : "production"; if (!loading && !action) { return ( @@ -41,7 +42,15 @@ export const ActionIdPage = (props: ActionIdPageProps) => { return (
- + {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 ( +
+ setHoverIndex(null) : undefined} + > + + + + {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
}> + + , + ); + }); + + expect( + await screen.findByRole("heading", { + name: "Unique Verifications", + }), + ).toBeInTheDocument(); + expect(screen.getByText("4")).toBeInTheDocument(); + expect(screen.getByText("Recent verifications")).toBeInTheDocument(); + expect(screen.getByText("Nullifier")).toBeInTheDocument(); + expect(screen.getByText(/1234567890/)).toBeInTheDocument(); + expect(screen.queryByText(/Verified humans/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/^Human$/i)).not.toBeInTheDocument(); + + expect(useQueryMock).toHaveBeenCalledTimes(1); + const feedDocument = print(useQueryMock.mock.calls[0][0]); + expect(feedDocument.replace(/\s+/g, " ")).toMatch( + /nullifiers\s*\(\s*limit:\s*100/i, + ); + expect(feedDocument).toMatch(/is_staging/); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + expect( + fetchMock.mock.calls.some( + (call) => + requestUrl(call[0]).searchParams.get("environment") === + "production", + ), + ).toBe(true); + }); + + it("maps staging apps to the staging analytics environment", async () => { + useQueryMock.mockReturnValue({ + data: legacyDetailData(true), + loading: false, + error: undefined, + }); + + await act(async () => { + render( + loading
}> + + , + ); + }); + + expect( + await screen.findByRole("heading", { + name: "Unique Verifications", + }), + ).toBeInTheDocument(); + await waitFor(() => + expect( + fetchMock.mock.calls.some( + (call) => + requestUrl(call[0]).searchParams.get("environment") === "staging", + ), + ).toBe(true), + ); + }); + }, +); +// #endregion