Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1c75d47
feat(analytics): app-scoped World ID analytics read endpoint
soamdesai-tfh Jul 31, 2026
e7030b9
test(analytics): restore million and integration runner paths
soamdesai-tfh Jul 31, 2026
06d407d
Merge branch 'feat/world-id-analytics-data' into feat/world-id-analyt…
soamdesai-tfh Aug 1, 2026
d124e5a
feat(portal): Unique Verifications analytics UI
soamdesai-tfh Jul 31, 2026
e30eb59
test: select analytics periods through the dropdown selector
soamdesai-tfh Jul 31, 2026
5c76835
fix(portal): archived-quality analytics graph presentation
soamdesai-tfh Jul 31, 2026
7e6db14
fix(portal): keep the period selector on one line in narrow cards
soamdesai-tfh Jul 31, 2026
2cddd4b
test: drop the hero legacy-inclusion disclosure assertion
soamdesai-tfh Jul 31, 2026
55de5d6
fix(portal): remove the legacy-inclusion help text from the app hero
soamdesai-tfh Jul 31, 2026
18cf3b4
fix(portal): render the flat zero graph for single-point series
soamdesai-tfh Jul 31, 2026
1db563a
test: cover the single-point flat graph
soamdesai-tfh Jul 31, 2026
8d02a00
fix(portal): darken the graph hover rule
soamdesai-tfh Jul 31, 2026
fff85e1
fix(portal): restore the archived action-card layout
soamdesai-tfh Jul 31, 2026
5b9199a
fix(portal): stop resizing the shared app-card frame
soamdesai-tfh Jul 31, 2026
9477c28
fix(analytics): align World ID surfaces with environment rules
soamdesai-tfh Jul 31, 2026
d991ea2
style(analytics): format surface coverage
soamdesai-tfh Jul 31, 2026
8e9fd40
fix(portal): adapt analytics surfaces to World ID layout
soamdesai-tfh Aug 1, 2026
690969c
fix(portal): keep legacy action skeleton height stable
soamdesai-tfh Aug 1, 2026
b35e2a1
fix(portal): keep verification feed pagination visible
soamdesai-tfh Aug 1, 2026
d3af4cb
Merge remote-tracking branch 'origin/feat/world-id-analytics-read' in…
soamdesai-tfh Aug 4, 2026
e621e38
test(portal): adapt analytics surface coverage to the redesigned acti…
soamdesai-tfh Aug 5, 2026
2da7ae1
Merge remote-tracking branch 'origin/feat/world-id-analytics-read' in…
soamdesai-tfh Aug 5, 2026
0e0b19c
Merge remote-tracking branch 'origin/feat/world-id-analytics-read' in…
soamdesai-tfh Aug 6, 2026
f56d690
Merge remote-tracking branch 'origin/feat/world-id-analytics-read' in…
soamdesai-tfh Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions web/lib/world-id-analytics.ts
Original file line number Diff line number Diff line change
@@ -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)}`;
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const VerifiedRow = (props: {
<Typography
className="text-grey-700"
variant={TYPOGRAPHY.R3}
>{`${nullifier.nullifier_hash.slice(0, 10)}...${nullifier.nullifier_hash.slice(-8)}`}</Typography>
>{`${nullifier.nullifier_hash.slice(0, 12)}...${nullifier.nullifier_hash.slice(-8)}`}</Typography>

<Typography
className="block text-grey-500 md:hidden"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type VerifiedTableColumn = "human" | "uses" | "time";
const getColumnLabel = (column: VerifiedTableColumn): string => {
switch (column) {
case "human":
return "Human";
return "Nullifier";
case "uses":
return "Uses";
case "time":
Expand Down Expand Up @@ -108,7 +108,7 @@ export const VerifiedTable = (props: {
<div className="flex w-full items-center justify-end">
<div className="grid w-full gap-y-6">
<div className="mt-6 flex items-center justify-start gap-x-2">
<Typography variant={TYPOGRAPHY.H7}>Verified humans</Typography>
<Typography variant={TYPOGRAPHY.H7}>Recent verifications</Typography>

<Typography
variant={TYPOGRAPHY.R5}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { use } from "react";
import { EngineType } from "@/lib/types";
import { ErrorPage } from "@/components/ErrorPage";
import Skeleton from "react-loading-skeleton";
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";
Expand All @@ -24,6 +24,7 @@ export const ActionIdPage = (props: ActionIdPageProps) => {
});

const action = data?.action[0];
const environment = action?.app.is_staging ? "staging" : "production";

if (!loading && !action) {
return (
Expand All @@ -41,7 +42,15 @@ export const ActionIdPage = (props: ActionIdPageProps) => {
return (
<SizingWrapper gridClassName="pt-6 pb-6 md:pb-10">
<div className="grid w-full grid-cols-1 items-start justify-between gap-y-10 lg:grid-cols-2 lg:gap-x-32">
<ActionStatsGraph />
<WorldIdAnalyticsGraph
appId={appId ?? ""}
environment={environment}
scope={{
type: "action",
source: "legacy",
actionId: actionId ?? "",
}}
/>

{loading ? (
<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const VerifiedRow = (props: {
<Typography
className="text-grey-700"
variant={TYPOGRAPHY.R3}
>{`${nullifier.nullifier_hash.slice(0, 10)}...${nullifier.nullifier_hash.slice(-8)}`}</Typography>
>{`${nullifier.nullifier_hash.slice(0, 12)}...${nullifier.nullifier_hash.slice(-8)}`}</Typography>

<Typography
className="block text-grey-500 md:hidden"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type VerifiedTableColumn = "human" | "uses" | "time";
const getColumnLabel = (column: VerifiedTableColumn): string => {
switch (column) {
case "human":
return "Human";
return "Nullifier";
case "uses":
return "Uses";
case "time":
Expand All @@ -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);
Expand Down Expand Up @@ -110,7 +117,7 @@ export const VerifiedTable = (props: {
<div className="flex w-full items-center justify-end">
<div className="grid w-full gap-y-6">
<div className="mt-6 flex items-center justify-start gap-x-2">
<Typography variant={TYPOGRAPHY.H7}>Verified humans</Typography>
<Typography variant={TYPOGRAPHY.H7}>Recent verifications</Typography>

{showCount ? (
<Typography
Expand Down Expand Up @@ -161,14 +168,16 @@ export const VerifiedTable = (props: {
</div>
</div>

<Pagination
totalResults={totalResultsCount}
currentPage={currentPage}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={rowsPerPageOptions}
handlePageChange={handlePageChange}
handleRowsPerPageChange={handleRowsPerPageChange}
/>
{showPagination ? (
<Pagination
totalResults={totalResultsCount}
currentPage={currentPage}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={rowsPerPageOptions}
handlePageChange={handlePageChange}
handleRowsPerPageChange={handleRowsPerPageChange}
/>
) : null}
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 (
Expand All @@ -42,14 +43,22 @@ export const ActionIdPage = (props: ActionIdPageProps) => {
return (
<SizingWrapper gridClassName="pt-6 pb-6 md:pb-10">
<div className="grid w-full grid-cols-1 items-start justify-between gap-y-10 lg:grid-cols-2 lg:gap-x-32">
<ActionStatsGraph />
<WorldIdAnalyticsGraph
appId={appId ?? ""}
environment={environment}
scope={{
type: "action",
source: "legacy",
actionId: actionId ?? "",
}}
/>

{loading ? (
<div className="grid w-full gap-y-6">
<Typography variant={TYPOGRAPHY.H7} className="mt-6">
Verified humans
Recent verifications
</Typography>
<SkeletonTable columns={["Human", "Uses", "Time"]} rows={5} />
<SkeletonTable columns={["Nullifier", "Uses", "Time"]} rows={5} />
</div>
) : (
<VerifiedTable
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use client";

import {
Select,
SelectButton,
SelectOption,
SelectOptions,
} from "@/components/Select";
import { Icon, opticalIconClassName } from "@/scenes/PortalV3/common/Icon";

export type AnalyticsPeriod = "all_time" | "last_7_days";

const periodOptions = [
{ value: "last_7_days", label: "Last 7 Days" },
{ value: "all_time", label: "All Time" },
] satisfies Array<{ label: string; value: AnalyticsPeriod }>;

export const PeriodSelector = (props: {
onPeriodChange: (period: AnalyticsPeriod) => void;
period: AnalyticsPeriod;
}) => (
<Select value={props.period} onChange={props.onPeriodChange}>
<SelectButton
aria-label="Unique Verifications period"
className="flex h-10 shrink-0 items-center justify-center gap-2 rounded-8 border border-portal-border bg-white py-2.5 pr-3 pl-4 font-world text-13 leading-none whitespace-nowrap text-portal-heading transition-colors outline-none hover:bg-portal-canvas focus-visible:ring-2 focus-visible:ring-grey-300"
>
<span>
{periodOptions.find((option) => option.value === props.period)?.label}
</span>
<Icon name="chevron-down" className={`size-4 ${opticalIconClassName}`} />
</SelectButton>
<SelectOptions>
{periodOptions.map((option) => (
<SelectOption
key={option.value}
value={option.value}
className="font-world text-13 text-portal-heading hover:bg-portal-canvas"
>
{option.label}
</SelectOption>
))}
</SelectOptions>
</Select>
);
Original file line number Diff line number Diff line change
@@ -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<number | null>(null);
const interactive = points.some((point) => point.label);

const handleMouseMove = useCallback(
(event: React.MouseEvent<SVGSVGElement>) => {
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 (
<div className={`relative ${props.className ?? ""}`}>
<svg
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
preserveAspectRatio="none"
className="size-full"
role="img"
aria-label={props.ariaLabel}
onMouseMove={interactive ? handleMouseMove : undefined}
onMouseLeave={interactive ? () => setHoverIndex(null) : undefined}
>
<polyline
points={polyline}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
data-flat-zero={flatZero ? "true" : undefined}
/>
</svg>

{interactive && hover !== null ? (
<>
<div
className="pointer-events-none absolute inset-y-0 border-l border-dashed border-portal-subtle"
style={{ left: `${hoverLeftPct}%` }}
/>
<div
className="pointer-events-none absolute bottom-full z-20 mb-2 flex flex-col gap-0.5 rounded-8 border border-portal-border bg-white px-2.5 py-1.5 whitespace-nowrap shadow-portal-card"
style={{
left: `${hoverLeftPct}%`,
transform: `translateX(${tooltipShift})`,
}}
>
<span className="font-world text-13 font-medium text-portal-heading">
{Number(points[hover].count).toLocaleString()}{" "}
{points[hover].count === "1" ? "verification" : "verifications"}
</span>
<span className="font-world text-12 text-portal-muted">
{points[hover].label}
</span>
</div>
</>
) : null}
</div>
);
};
Loading
Loading