Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d32a58e
feat(agents): add analytics and GEO tools for eve and Iris
mezotv Aug 2, 2026
76e8070
fix(analytics): render verification badges and window label in leader…
mezotv Aug 2, 2026
a06740f
fix(analytics): show hovered day heading in chart tooltips
mezotv Aug 2, 2026
9514ad2
fix(geo): show hovered day heading in mention rate tooltip
mezotv Aug 2, 2026
d0ea5ab
fix(analytics): square business avatars, header connect buttons
mezotv Aug 2, 2026
8c5698d
fix(analytics): gate account tracking behind lookup and confirm dialog
mezotv Aug 2, 2026
df27048
feat(geo): per-model engines and model usage market share
mezotv Aug 2, 2026
fbeba5a
perf(analytics): latest-state materialized views and array param fix
mezotv Aug 2, 2026
f0d0fc7
feat(beacon): add AI traffic middleware package with signature classi…
mezotv Aug 2, 2026
716a724
feat(geo): restructure overview into sectioned dashboard
mezotv Aug 2, 2026
38c0624
fix(geo): contain request log table in bordered wrapper
mezotv Aug 2, 2026
9dccd30
fix(geo): remove body corner notch in request log table
mezotv Aug 2, 2026
ae4fa81
feat(geo): classify prompt presence as training data, retrieval, or i…
mezotv Aug 2, 2026
7df1d41
feat(analytics): add impressions share donut and pie radar chart prim…
mezotv Aug 2, 2026
14d90d9
feat(geo): add engine radar, share of voice donut, traffic purpose donut
mezotv Aug 2, 2026
894ed99
style(analytics): instrument panel synthesis from design tournament
mezotv Aug 2, 2026
a015938
style(geo): instrument panel synthesis from design tournament
mezotv Aug 2, 2026
11d7a64
feat(analytics): add upstash redis cache over tinybird queries
mezotv Aug 2, 2026
255afef
fix(analytics): purge removed accounts from clickhouse and add pointe…
mezotv Aug 2, 2026
d9751ba
style(geo): rebalance overview grid and split traffic log
mezotv Aug 2, 2026
b63bf0d
feat(geo): multi-language prompt tracking with per-language performance
mezotv Aug 2, 2026
df3d0d7
chore(db): add languages column to geo settings
mezotv Aug 2, 2026
50839b4
feat(analytics): simplify leaderboard header and columns
mezotv Aug 3, 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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,9 @@ AXIOM_ORG_ID=""

# Notra
NOTRA_API_KEY=""

# Beacon (AI traffic detection)
BEACON_INGEST_SECRET=
BEACON_INGEST_URL="http://localhost:3002/api/beacon"
BEACON_ORG_TOKEN=
BEACON_ORG_ID=
2 changes: 2 additions & 0 deletions apps/agent/agent/tools/create_ab_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { createCreateAbTestTool } from "@notra/tools/analytics/create-ab-test";
export default createCreateAbTestTool();
2 changes: 2 additions & 0 deletions apps/agent/agent/tools/get_ab_tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { createGetAbTestsTool } from "@notra/tools/analytics/get-ab-tests";
export default createGetAbTestsTool();
3 changes: 3 additions & 0 deletions apps/agent/agent/tools/get_engagement_timeseries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createGetEngagementTimeseriesTool } from "@notra/tools/analytics/get-engagement-timeseries";

export default createGetEngagementTimeseriesTool();
2 changes: 2 additions & 0 deletions apps/agent/agent/tools/get_geo_overview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { createGetGeoOverviewTool } from "@notra/tools/analytics/get-geo-overview";
export default createGetGeoOverviewTool();
3 changes: 3 additions & 0 deletions apps/agent/agent/tools/get_posting_performance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createGetPostingPerformanceTool } from "@notra/tools/analytics/get-posting-performance";

export default createGetPostingPerformanceTool();
3 changes: 3 additions & 0 deletions apps/agent/agent/tools/get_social_analytics_overview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createGetSocialAnalyticsOverviewTool } from "@notra/tools/analytics/get-social-analytics-overview";

export default createGetSocialAnalyticsOverviewTool();
3 changes: 3 additions & 0 deletions apps/agent/agent/tools/get_top_posts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createGetTopPostsTool } from "@notra/tools/analytics/get-top-posts";

export default createGetTopPostsTool();
211 changes: 158 additions & 53 deletions apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
"use client";

import type { ChartConfig } from "@notra/ui/components/dither-kit/chart-context";
import { useReducedMotion } from "motion/react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { AccountFilter } from "@/components/analytics/account-filter";
import { AccountSeriesChartCard } from "@/components/analytics/account-series-chart-card";
import { ConnectAccountsButtons } from "@/components/analytics/connect-accounts-buttons";
import { FollowersCard } from "@/components/analytics/followers-card";
import { ImpressionsShareCard } from "@/components/analytics/impressions-share-card";
import { LeaderboardCard } from "@/components/analytics/leaderboard-card";
import { PostingPerformanceCard } from "@/components/analytics/posting-performance-card";
import { SummaryStats } from "@/components/analytics/summary-stats";
import { TopPostsCard } from "@/components/analytics/top-posts-card";
import { EmptyState } from "@/components/empty-state";
import { InstrumentGrid } from "@/components/instrument/instrument-grid";
import { InstrumentReveal } from "@/components/instrument/instrument-reveal";
import { PageContainer } from "@/components/layout/container";
import { SectionHeader } from "@/components/layout/section-header";
import { useOrganizationsContext } from "@/components/providers/organization-provider";
import {
ACCOUNT_SERIES_COLORS,
Expand All @@ -26,6 +30,7 @@ import {
useSocialOverview,
useTopPosts,
} from "@/lib/hooks/use-social-analytics";
import { cn } from "@/lib/utils";
import type { TimelineMarker } from "@/types/analytics";
import {
accountSeriesKey,
Expand All @@ -34,8 +39,33 @@ import {
buildTimelineDays,
markerIndexForDate,
} from "@/utils/analytics-charts";
import { formatSyncClock } from "@/utils/instrument";
import { AnalyticsPageSkeleton } from "./skeleton";

/* ─────────────────────────────────────────────────────────
* ANIMATION STORYBOARD — Analytics instrument panel
*
* Read top-to-bottom. Each `at` value is ms after data mount.
*
* 0ms header + account switches render statically
* 60ms master readout rail powers on,
* sync dot starts pulsing
* 150ms modules materialize over the grid substrate
* (staggered 45ms in reading order)
*
* Reduced motion: everything appears at once, no offsets.
* ───────────────────────────────────────────────────────── */

const TIMING = {
readoutRail: 60, // master readout rail powers on
modules: 150, // grid modules start staggering in
};

const STAGE = {
rail: 1, // readout rail visible
modules: 2, // grid modules visible
};

interface PageClientProps {
organizationSlug: string;
}
Expand All @@ -49,8 +79,11 @@ export default function PageClient({ organizationSlug }: PageClientProps) {
: orgFromList;
const organizationId = organization?.id ?? "";

const { data: overview, isPending: isOverviewPending } =
useSocialOverview(organizationId);
const {
data: overview,
isPending: isOverviewPending,
dataUpdatedAt,
} = useSocialOverview(organizationId);
const { data: engagement } = useEngagementTimeseries(organizationId);
const { data: followerGrowth } = useFollowerGrowth(organizationId);
const { data: topPosts } = useTopPosts(organizationId);
Expand All @@ -60,6 +93,29 @@ export default function PageClient({ organizationSlug }: PageClientProps) {
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const [hiddenKeys, setHiddenKeys] = useState<Set<string>>(new Set());

const reduceMotion = useReducedMotion();
const [stage, setStage] = useState(0);
const ready = !isOverviewPending;

useEffect(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/effect-needs-cleanup (error)

setTimeout creates a timer in useEffect without guaranteed cleanup. Return a cleanup function that owns every allocation so it does not leak after unmount.

Fix → Return a cleanup function that stops the subscription or timer: return () => target.removeEventListener(name, handler) for listeners, return () => clearInterval(id) or clearTimeout(id) for timers, return () => observer.disconnect() for observers, return () => socket.close() for connections, or return unsubscribe if the subscribe call already gave you one.

Docs

if (!ready) {
setStage(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-hooks-js/set-state-in-effect (warning)

This synchronous effect update causes an extra render: Calling setState synchronously within an effect can trigger cascading renders. Prefer deriving or initializing the value before render. If the effect must read a browser API after mount, treat this as advisory or suppress it with // react-doctor-disable-next-line react-hooks-js/set-state-in-effect.

Fix → Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:

  • Update external systems with the latest state from React.
  • Subscribe for updates from some external system, calling setState in a callback function when external state changes.

Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).

Docs

return;
}
if (reduceMotion) {
setStage(STAGE.modules);
return;
}
const timers: ReturnType<typeof setTimeout>[] = [];
timers.push(setTimeout(() => setStage(STAGE.rail), TIMING.readoutRail));
timers.push(setTimeout(() => setStage(STAGE.modules), TIMING.modules));
return () => {
for (const timer of timers) {
clearTimeout(timer);
}
};
}, [ready, reduceMotion]);

const accounts = useMemo(
() => overview?.accounts ?? [],
[overview?.accounts]
Expand Down Expand Up @@ -176,10 +232,12 @@ export default function PageClient({ organizationSlug }: PageClientProps) {
<PageContainer className="flex flex-1 flex-col gap-4 py-4 md:gap-6 md:py-6">
<div className="w-full space-y-6 px-4 lg:px-6">
<header className="space-y-1">
<h1 className="font-semibold text-2xl">Analytics</h1>
<p className="text-muted-foreground text-sm">
Performance of your connected X and LinkedIn accounts
<p className="font-mono text-muted-foreground text-xs uppercase tracking-wider">
X + LinkedIn instrument panel
</p>
<h1 className="font-semibold text-xl tracking-tight">
Nothing to report yet.
</h1>
</header>
<EmptyState
action={
Expand All @@ -200,14 +258,31 @@ export default function PageClient({ organizationSlug }: PageClientProps) {

return (
<PageContainer className="flex flex-1 flex-col gap-4 py-4 md:gap-6 md:py-6">
<div className="w-full space-y-6 px-4 lg:px-6">
<div className="w-full space-y-4 px-4 lg:px-6">
<header className="space-y-3">
<div className="space-y-1">
<h1 className="font-semibold text-2xl">Analytics</h1>
<p className="text-muted-foreground text-sm">
{accounts.length} {accounts.length === 1 ? "account" : "accounts"}{" "}
connected · hover any chart to compare the same day everywhere
</p>
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="space-y-1">
<h1 className="font-semibold text-xl tracking-tight">
Analytics
</h1>
<p className="flex flex-wrap items-center gap-x-1.5 font-mono text-[0.6875rem] text-muted-foreground uppercase tracking-wider">
<span>
{accounts.length}{" "}
{accounts.length === 1 ? "account" : "accounts"} · X +
LinkedIn · {ANALYTICS_TIMESERIES_DAYS}D window · Sync{" "}
{formatSyncClock(dataUpdatedAt || null)}
</span>
<span
aria-hidden="true"
className={cn(
"size-1.5 rounded-full bg-emerald-500",
stage >= STAGE.rail &&
"animate-pulse motion-reduce:animate-none"
)}
/>
</p>
</div>
<ConnectAccountsButtons organizationId={organizationId} />
</div>
<AccountFilter
accounts={accounts}
Expand All @@ -217,92 +292,122 @@ export default function PageClient({ organizationSlug }: PageClientProps) {
</header>

{overview?.configured === false && (
<p className="rounded-md border bg-muted/40 px-3 py-2 text-muted-foreground text-sm">
<p className="rounded-md border border-border px-3 py-2 font-mono text-[0.6875rem] text-muted-foreground">
Analytics ingestion is not configured yet. Connected accounts are
shown, but stats will appear once the analytics backend is set up.
</p>
)}

<SummaryStats accounts={accounts} points={engagement?.points ?? []} />

<section className="space-y-3">
<SectionHeader
description="Every account you post from or track, ranked by interactions"
title="Accounts"
/>
<LeaderboardCard
accountDetails={accounts}
organizationId={organizationId}
/>
</section>
<InstrumentReveal active={stage >= STAGE.rail}>
<SummaryStats accounts={accounts} points={engagement?.points ?? []} />
</InstrumentReveal>

<section className="space-y-3">
<SectionHeader
description="Hover any chart to compare the same day everywhere"
title="Trends"
/>
<div className="grid gap-3 lg:grid-cols-2">
<InstrumentGrid className="grid-cols-1 lg:grid-cols-12">
<InstrumentReveal
active={stage >= STAGE.modules}
className="lg:col-span-8"
order={0}
>
<AccountSeriesChartCard
allKeys={allKeys}
config={accountConfig}
description="Likes, replies, and reposts by publish date"
emptyMessage="No engagement data yet"
hero
hiddenKeys={hiddenKeys}
hoverIndex={hoverIndex}
kind="area"
markers={markers}
onHoverChange={setHoverIndex}
onToggleSeries={toggleAccount}
readout={`${ANALYTICS_TIMESERIES_DAYS}D`}
rows={engagementRows}
title="Engagement"
/>
</InstrumentReveal>
<InstrumentReveal
active={stage >= STAGE.modules}
className="lg:col-span-4"
order={1}
>
<FollowersCard
accounts={accounts}
colorForKey={(key) => accountConfig[key]?.color ?? "blue"}
hiddenKeys={hiddenKeys}
points={followerGrowth?.points ?? []}
/>
</InstrumentReveal>
<InstrumentReveal
active={stage >= STAGE.modules}
className="lg:col-span-8"
order={2}
>
<LeaderboardCard
accountDetails={accounts}
organizationId={organizationId}
/>
</InstrumentReveal>
<InstrumentReveal
active={stage >= STAGE.modules}
className="lg:col-span-4"
order={3}
>
<ImpressionsShareCard organizationId={organizationId} />
</InstrumentReveal>
<InstrumentReveal
active={stage >= STAGE.modules}
className="lg:col-span-6"
order={4}
>
<AccountSeriesChartCard
allKeys={allKeys}
config={accountConfig}
description="Impressions by publish date"
emptyMessage="No impression data yet"
hiddenKeys={hiddenKeys}
hoverIndex={hoverIndex}
kind="area"
markers={markers}
onHoverChange={setHoverIndex}
onToggleSeries={toggleAccount}
readout={`${ANALYTICS_TIMESERIES_DAYS}D`}
rows={impressionRows}
title="Impressions"
/>
</InstrumentReveal>
<InstrumentReveal
active={stage >= STAGE.modules}
className="lg:col-span-6"
order={5}
>
<AccountSeriesChartCard
allKeys={allKeys}
config={accountConfig}
description="Posts published per day"
emptyMessage="No posts tracked yet"
hiddenKeys={hiddenKeys}
hoverIndex={hoverIndex}
kind="bar"
markers={markers}
onHoverChange={setHoverIndex}
onToggleSeries={toggleAccount}
readout={`${ANALYTICS_TIMESERIES_DAYS}D`}
rows={postRows}
title="Publishing volume"
/>
<FollowersCard
accounts={accounts}
colorForKey={(key) => accountConfig[key]?.color ?? "blue"}
hiddenKeys={hiddenKeys}
points={followerGrowth?.points ?? []}
/>
</div>
</section>

<section className="space-y-3">
<SectionHeader
description="When to post and which posts carry the numbers"
title="What works"
/>
<div className="grid gap-3 lg:grid-cols-2">
</InstrumentReveal>
<InstrumentReveal
active={stage >= STAGE.modules}
className="lg:col-span-6"
order={6}
>
<PostingPerformanceCard rows={performanceRows} />
</InstrumentReveal>
<InstrumentReveal
active={stage >= STAGE.modules}
className="lg:col-span-6"
order={7}
>
<TopPostsCard posts={topPosts?.posts ?? []} />
</div>
</section>
</InstrumentReveal>
</InstrumentGrid>
</div>
</PageContainer>
);
Expand Down
Loading
Loading