-
Notifications
You must be signed in to change notification settings - Fork 39
feat(agents): analytics + GEO tools for eve and Iris #649
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/geo
Are you sure you want to change the base?
Changes from all commits
d32a58e
76e8070
a06740f
9514ad2
d0ea5ab
8c5698d
df27048
fbeba5a
f0d0fc7
716a724
38c0624
9dccd30
ae4fa81
7df1d41
14d90d9
894ed99
a015938
11d7a64
255afef
d9751ba
b63bf0d
df3d0d7
50839b4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(); |
| 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(); |
| 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(); |
| 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(); |
| 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(); |
| 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(); |
| 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(); |
| 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, | ||
|
|
@@ -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, | ||
|
|
@@ -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; | ||
| } | ||
|
|
@@ -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); | ||
|
|
@@ -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(() => { | ||
| if (!ready) { | ||
| setStage(0); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. React Doctor · 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 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:
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). |
||
| 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] | ||
|
|
@@ -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={ | ||
|
|
@@ -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} | ||
|
|
@@ -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> | ||
| ); | ||
|
|
||
There was a problem hiding this comment.
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)setTimeoutcreates 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)orclearTimeout(id)for timers,return () => observer.disconnect()for observers,return () => socket.close()for connections, orreturn unsubscribeif the subscribe call already gave you one.Docs