diff --git a/.gitignore b/.gitignore index cb7d38720..6746e7bf3 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,4 @@ tmp # next-agents-md .next-docs/ .gstack/ +.superpowers/ diff --git a/apps/api/scripts/agent-cost-probe.ts b/apps/api/scripts/agent-cost-probe.ts index 74e5d14ef..fc1d89ed4 100644 --- a/apps/api/scripts/agent-cost-probe.ts +++ b/apps/api/scripts/agent-cost-probe.ts @@ -58,12 +58,11 @@ if (!(websiteId && userId)) { process.exit(1); } -// Matches creditSchema in apps/dashboard/autumn.config.ts. const CURRENT_SCHEMA = { - input: 0.0006, - output: 0.003, - cacheRead: 0.000_06, - cacheWrite: 0.000_75, + input: 0.000_72, + output: 0.0036, + cacheRead: 0.000_072, + cacheWrite: 0.001_44, }; function computeCredits( diff --git a/apps/api/src/ai/agents/analytics.ts b/apps/api/src/ai/agents/analytics.ts index 222a8c9f4..fc5cc283e 100644 --- a/apps/api/src/ai/agents/analytics.ts +++ b/apps/api/src/ai/agents/analytics.ts @@ -60,6 +60,7 @@ export function createConfig(context: AgentContext): AgentConfig { currentDateTime: new Date().toISOString(), chatId: context.chatId, requestHeaders: context.requestHeaders, + billingCustomerId: context.billingCustomerId, }; return { diff --git a/apps/api/src/ai/agents/types.ts b/apps/api/src/ai/agents/types.ts index e612a74a4..103edcf32 100644 --- a/apps/api/src/ai/agents/types.ts +++ b/apps/api/src/ai/agents/types.ts @@ -28,6 +28,7 @@ export const AGENT_THINKING_LEVELS: readonly AgentThinking[] = [ ] as const; export interface AgentContext { + billingCustomerId?: string | null; chatId: string; requestHeaders?: Headers; thinking?: AgentThinking; diff --git a/apps/api/src/ai/config/context.ts b/apps/api/src/ai/config/context.ts index 79102dc40..3faad65fb 100644 --- a/apps/api/src/ai/config/context.ts +++ b/apps/api/src/ai/config/context.ts @@ -5,6 +5,7 @@ export interface AppContext { /** Available query builder types */ availableQueryTypes?: string[]; + billingCustomerId?: string | null; chatId: string; currentDateTime: string; requestHeaders?: Headers; diff --git a/apps/api/src/ai/tools/web-search.ts b/apps/api/src/ai/tools/web-search.ts index 7b6c901b9..9073c2595 100644 --- a/apps/api/src/ai/tools/web-search.ts +++ b/apps/api/src/ai/tools/web-search.ts @@ -1,3 +1,4 @@ +import { getAutumn } from "@databuddy/rpc"; import { generateText, tool } from "ai"; import { z } from "zod"; import type { AppContext } from "../config/context"; @@ -67,6 +68,23 @@ export const webSearchTool = tool({ responseLength: result.text.length, }); + if (appContext?.billingCustomerId) { + getAutumn() + .track({ + customerId: appContext.billingCustomerId, + featureId: "agent_web_search_calls", + value: 1, + }) + .catch((trackError) => { + logger.error("Failed to track web search usage", { + error: + trackError instanceof Error + ? trackError.message + : String(trackError), + }); + }); + } + // Sanitize web content before returning to the agent to prevent // indirect prompt injection from adversarial web pages. const sanitized = result.text.replace( diff --git a/apps/api/src/routes/agent.ts b/apps/api/src/routes/agent.ts index 9dee33513..c1515a6b2 100644 --- a/apps/api/src/routes/agent.ts +++ b/apps/api/src/routes/agent.ts @@ -354,6 +354,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) chatId, requestHeaders: request.headers, thinking: body.thinking, + billingCustomerId, }) ), isMemoryEnabled() && lastMessage diff --git a/apps/api/src/routes/insights.ts b/apps/api/src/routes/insights.ts index 4ce813580..e32e3464e 100644 --- a/apps/api/src/routes/insights.ts +++ b/apps/api/src/routes/insights.ts @@ -13,7 +13,8 @@ import { member, websites, } from "@databuddy/db"; -import { getRedisCache } from "@databuddy/redis"; +import { cacheable, getRedisCache } from "@databuddy/redis"; +import { getRateLimitHeaders, rateLimit } from "@databuddy/redis/rate-limit"; import { generateText, Output, stepCountIs, ToolLoopAgent } from "ai"; import dayjs from "dayjs"; import { Elysia, t } from "elysia"; @@ -23,7 +24,7 @@ import type { ParsedInsight } from "../ai/schemas/smart-insights-output"; import { insightsOutputSchema } from "../ai/schemas/smart-insights-output"; import { createInsightsAgentTools } from "../ai/tools/insights-agent-tools"; import { storeAnalyticsSummary } from "../lib/supermemory"; -import { mergeWideEvent } from "../lib/tracing"; +import { captureError, mergeWideEvent } from "../lib/tracing"; import { executeQuery } from "../query"; const CACHE_TTL = 900; @@ -927,6 +928,118 @@ async function invalidateInsightsCacheForOrg( } } +const NARRATIVE_RATE_LIMIT = 30; +const NARRATIVE_RATE_WINDOW_SECS = 3600; +const NARRATIVE_CACHE_TTL_SECS = 3600; +const NARRATIVE_INSIGHTS_LIMIT = 5; + +function rangeWord(range: "7d" | "30d" | "90d"): string { + if (range === "7d") { + return "week"; + } + if (range === "30d") { + return "month"; + } + return "quarter"; +} + +function buildDeterministicNarrative( + range: "7d" | "30d" | "90d", + topInsights: { + title: string; + severity: string; + websiteName: string | null; + }[] +): string { + const word = rangeWord(range); + if (topInsights.length === 0) { + return `All systems healthy this ${word}. No actionable signals detected.`; + } + const headline = topInsights[0]; + const siteSuffix = headline.websiteName ? ` on ${headline.websiteName}` : ""; + if (topInsights.length === 1) { + return `This ${word}: ${headline.title}${siteSuffix}.`; + } + const extra = topInsights.length - 1; + return `This ${word}: ${headline.title}${siteSuffix}, plus ${extra} more signal${extra === 1 ? "" : "s"} worth reviewing.`; +} + +const generateNarrativeCached = cacheable( + async function generateNarrativeCached( + organizationId: string, + range: "7d" | "30d" | "90d" + ): Promise<{ narrative: string }> { + const topInsights = await db + .select({ + title: analyticsInsights.title, + description: analyticsInsights.description, + severity: analyticsInsights.severity, + changePercent: analyticsInsights.changePercent, + websiteName: websites.name, + }) + .from(analyticsInsights) + .innerJoin(websites, eq(analyticsInsights.websiteId, websites.id)) + .where(eq(analyticsInsights.organizationId, organizationId)) + .orderBy(desc(analyticsInsights.priority)) + .limit(NARRATIVE_INSIGHTS_LIMIT); + + if (topInsights.length === 0) { + return { + narrative: `All systems healthy this ${rangeWord(range)}. No actionable signals detected.`, + }; + } + + const insightLines = topInsights.map((ins) => { + const site = ins.websiteName ? ` [${ins.websiteName}]` : ""; + const change = + ins.changePercent == null + ? "" + : ` (${ins.changePercent > 0 ? "+" : ""}${ins.changePercent.toFixed(0)}%)`; + return `- [${ins.severity}] ${ins.title}${change}${site}: ${ins.description ?? ""}`; + }); + + const prompt = `You are an analytics assistant summarizing an organization's state over the last ${range}. + +Write a crisp 2–3 sentence executive summary of the top insights below. + +Rules: +- Lead with the most important change +- Include concrete numbers when available +- Never exceed 60 words total +- State facts, do not editorialize +- If nothing meaningful is happening, say so plainly + +Top signals this ${range}: +${insightLines.join("\n")}`; + + let narrative = ""; + try { + const result = await generateText({ + model: models.analytics, + prompt, + temperature: 0.2, + maxOutputTokens: 200, + }); + narrative = result.text.trim(); + } catch (error) { + useLogger().warn("Narrative LLM call failed", { + insights: { organizationId, range, error }, + }); + } + + if (!narrative) { + narrative = buildDeterministicNarrative(range, topInsights); + mergeWideEvent({ insights_narrative_fallback: true }); + } + + return { narrative }; + }, + { + expireInSec: NARRATIVE_CACHE_TTL_SECS, + prefix: "insights-narrative", + } +); + export const insights = new Elysia({ prefix: "/v1/insights" }) .derive(async ({ request }) => { const session = await auth.api.getSession({ headers: request.headers }); @@ -1059,6 +1172,78 @@ export const insights = new Elysia({ prefix: "/v1/insights" }) }), } ) + .get( + "/org-narrative", + async ({ query, user, set }) => { + const userId = user?.id; + if (!userId) { + return { success: false, error: "User ID required" }; + } + + const { organizationId, range } = query; + mergeWideEvent({ + insights_narrative_org_id: organizationId, + insights_narrative_range: range, + }); + + const memberships = await db.query.member.findMany({ + where: eq(member.userId, userId), + columns: { organizationId: true }, + }); + + const orgIds = new Set(memberships.map((m) => m.organizationId)); + if (!orgIds.has(organizationId)) { + mergeWideEvent({ insights_narrative_access: "denied" }); + set.status = 403; + return { success: false, error: "Access denied to this organization" }; + } + + const rl = await rateLimit( + `insights:narrative:${organizationId}:${userId}`, + NARRATIVE_RATE_LIMIT, + NARRATIVE_RATE_WINDOW_SECS + ); + const rlHeaders = getRateLimitHeaders(rl); + for (const [key, value] of Object.entries(rlHeaders)) { + set.headers[key] = value; + } + if (!rl.success) { + set.status = 429; + return { + success: false, + error: "Rate limit exceeded. Try again later.", + }; + } + + try { + const { narrative } = await generateNarrativeCached( + organizationId, + range + ); + return { + success: true, + narrative, + generatedAt: new Date().toISOString(), + }; + } catch (error) { + captureError(error, { + insights_narrative_org_id: organizationId, + insights_narrative_range: range, + }); + useLogger().warn("Failed to generate org narrative", { + insights: { organizationId, range, error }, + }); + set.status = 500; + return { success: false, error: "Could not generate narrative" }; + } + }, + { + query: t.Object({ + organizationId: t.String(), + range: t.Union([t.Literal("7d"), t.Literal("30d"), t.Literal("90d")]), + }), + } + ) .post( "/clear", async ({ body, user, set }) => { diff --git a/apps/dashboard/app/(main)/home/_components/smart-insights-section.tsx b/apps/dashboard/app/(main)/home/_components/smart-insights-section.tsx index 06a8d415d..2a8fc3d23 100644 --- a/apps/dashboard/app/(main)/home/_components/smart-insights-section.tsx +++ b/apps/dashboard/app/(main)/home/_components/smart-insights-section.tsx @@ -1,209 +1,24 @@ "use client"; import { ArrowClockwiseIcon } from "@phosphor-icons/react"; -import { ArrowRightIcon } from "@phosphor-icons/react"; -import { BugIcon } from "@phosphor-icons/react"; -import { CaretDownIcon } from "@phosphor-icons/react"; import { CheckCircleIcon } from "@phosphor-icons/react"; -import { GaugeIcon } from "@phosphor-icons/react"; -import { LightningIcon } from "@phosphor-icons/react"; -import { RocketIcon } from "@phosphor-icons/react"; import { SparkleIcon } from "@phosphor-icons/react"; -import { TrendDownIcon } from "@phosphor-icons/react"; -import { TrendUpIcon } from "@phosphor-icons/react"; import { WarningCircleIcon } from "@phosphor-icons/react"; -import Link from "next/link"; -import { type ReactNode, useMemo, useState } from "react"; -import { InsightMetrics } from "@/components/insight-metrics"; +import { useState } from "react"; +import { InsightCard } from "@/app/(main)/insights/_components/insight-card"; import { Skeleton } from "@/components/ui/skeleton"; -import { - changePercentChipClassName, - formatSignedChangePercent, -} from "@/lib/insight-signal-key"; -import type { - Insight, - InsightSentiment, - InsightType, -} from "@/lib/insight-types"; +import type { Insight } from "@/lib/insight-types"; import { cn } from "@/lib/utils"; -function buildDiagnosticPrompt(insight: Insight): string { - const parts = [ - `Diagnose this issue on ${insight.websiteName ?? insight.websiteDomain}:`, - `"${insight.title}"`, - "", - `Context: ${insight.description}`, - ]; - - if (insight.changePercent !== undefined && insight.changePercent !== 0) { - parts.push(`Change: ${formatSignedChangePercent(insight.changePercent)}`); - } - - parts.push( - "", - "Investigate the root cause, check the relevant data for the last 7 days, and provide a clear explanation of what's happening and specific steps to fix or improve it." - ); - - return parts.join("\n"); -} - -const ICON_STYLES: Partial< - Record -> = { - error_spike: { - icon: , - color: "text-red-500", - bg: "bg-red-500/10", - }, - vitals_degraded: { - icon: , - color: "text-amber-500", - bg: "bg-amber-500/10", - }, - custom_event_spike: { - icon: , - color: "text-blue-500", - bg: "bg-blue-500/10", - }, - traffic_drop: { - icon: , - color: "text-red-500", - bg: "bg-red-500/10", - }, - traffic_spike: { - icon: , - color: "text-emerald-500", - bg: "bg-emerald-500/10", - }, - performance: { - icon: , - color: "text-violet-500", - bg: "bg-violet-500/10", - }, - uptime_issue: { - icon: , - color: "text-red-500", - bg: "bg-red-500/10", - }, -}; - -const DEFAULT_ICON = { - icon: , - color: "text-primary", - bg: "bg-primary/10", -}; - -const SENTIMENT_STYLE: Record< - InsightSentiment, - { text: string; color: string } -> = { - positive: { text: "Positive", color: "text-emerald-600" }, - neutral: { text: "Neutral", color: "text-muted-foreground" }, - negative: { text: "Needs attention", color: "text-red-500" }, -}; - -function InsightRow({ insight }: { insight: Insight }) { +function InsightRowWrapper({ insight }: { insight: Insight }) { const [expanded, setExpanded] = useState(false); - const style = ICON_STYLES[insight.type] ?? DEFAULT_ICON; - const sentiment = - SENTIMENT_STYLE[insight.sentiment] ?? SENTIMENT_STYLE.neutral; - - const agentHref = useMemo(() => { - const chatId = crypto.randomUUID(); - const prompt = encodeURIComponent(buildDiagnosticPrompt(insight)); - return `/websites/${insight.websiteId}/agent/${chatId}?prompt=${prompt}`; - }, [insight]); - return ( - + setExpanded((prev) => !prev)} + variant="compact" + /> ); } @@ -398,14 +213,14 @@ export function SmartInsightsSection({ {showInsights && (
{insights.map((insight) => ( - + ))}
)} diff --git a/apps/dashboard/app/(main)/home/hooks/use-smart-insights.ts b/apps/dashboard/app/(main)/home/hooks/use-smart-insights.ts index 560208bb1..7d1a898c2 100644 --- a/apps/dashboard/app/(main)/home/hooks/use-smart-insights.ts +++ b/apps/dashboard/app/(main)/home/hooks/use-smart-insights.ts @@ -1,125 +1,24 @@ "use client"; -import { - keepPreviousData, - useQuery, - useQueryClient, -} from "@tanstack/react-query"; -import { useCallback, useMemo } from "react"; -import { useOrganizationsContext } from "@/components/providers/organizations-provider"; -import { - fetchInsightsAi, - fetchInsightsHistoryPage, - INSIGHT_CACHE, - INSIGHT_QUERY_KEYS, -} from "@/lib/insight-api"; -import type { Insight } from "@/lib/insight-types"; -import { mapHistoryRowToInsight } from "@/lib/insight-types"; +import { useMemo } from "react"; +import { useInsightsFeed } from "@/app/(main)/insights/hooks/use-insights-feed"; const INSIGHTS_MAX = 20; -function mergeInsights(fresh: Insight[], stored: Insight[]): Insight[] { - const seen = new Set(); - const out: Insight[] = []; - for (const i of [...fresh, ...stored]) { - if (!seen.has(i.id)) { - seen.add(i.id); - out.push(i); - if (out.length >= INSIGHTS_MAX) { - break; - } - } - } - return out; -} - export function useSmartInsights() { - const queryClient = useQueryClient(); - const { - activeOrganization, - activeOrganizationId, - isLoading: isOrgContextLoading, - } = useOrganizationsContext(); - - const orgId = activeOrganization?.id ?? activeOrganizationId ?? undefined; - - const historyQuery = useQuery({ - queryKey: [INSIGHT_QUERY_KEYS.history, orgId], - queryFn: () => fetchInsightsHistoryPage(orgId ?? "", 0, INSIGHTS_MAX), - enabled: !!orgId, - staleTime: INSIGHT_CACHE.historyStaleTime, - gcTime: INSIGHT_CACHE.gcTime, - refetchOnWindowFocus: false, - placeholderData: keepPreviousData, - retry: 2, - retryDelay: (attempt) => Math.min(2000 * 2 ** attempt, 15_000), - }); - - const aiQuery = useQuery({ - queryKey: [INSIGHT_QUERY_KEYS.ai, orgId], - queryFn: () => fetchInsightsAi(orgId ?? ""), - enabled: !!orgId, - staleTime: INSIGHT_CACHE.staleTime, - gcTime: INSIGHT_CACHE.gcTime, - refetchInterval: INSIGHT_CACHE.staleTime, - refetchOnWindowFocus: false, - placeholderData: keepPreviousData, - retry: 2, - retryDelay: (attempt) => Math.min(2000 * 2 ** attempt, 15_000), - }); - - const mergedInsights = useMemo(() => { - const fresh = (aiQuery.data?.insights ?? []).map( - (i): Insight => ({ - ...i, - insightSource: "ai", - }) - ); - const stored = (historyQuery.data?.insights ?? []).map( - mapHistoryRowToInsight - ); - return mergeInsights(fresh, stored); - }, [aiQuery.data?.insights, historyQuery.data?.insights]); - - const refetchInsights = useCallback(async () => { - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: [INSIGHT_QUERY_KEYS.history, orgId], - }), - queryClient.invalidateQueries({ - queryKey: [INSIGHT_QUERY_KEYS.ai, orgId], - }), - ]); - }, [queryClient, orgId]); - - const isInitialLoading = - isOrgContextLoading || - Boolean( - orgId && - !(historyQuery.isFetched && aiQuery.isFetched) && - !(historyQuery.isError && aiQuery.isError) - ); - - const isError = - mergedInsights.length === 0 && - historyQuery.isFetched && - aiQuery.isFetched && - (historyQuery.isError || aiQuery.isError); - - const isFetching = historyQuery.isFetching || aiQuery.isFetching; - - const isRefreshing = isFetching && !isInitialLoading; - - const isFetchingFresh = mergedInsights.length > 0 && aiQuery.isFetching; - + const feed = useInsightsFeed(); + const insights = useMemo( + () => feed.insights.slice(0, INSIGHTS_MAX), + [feed.insights] + ); return { - insights: mergedInsights, - source: aiQuery.data?.source ?? null, - isLoading: isInitialLoading, - isRefreshing, - isFetching, - isFetchingFresh, - isError, - refetch: refetchInsights, + insights, + source: feed.source, + isLoading: feed.isLoading, + isRefreshing: feed.isRefreshing, + isFetching: feed.isFetching, + isFetchingFresh: feed.isFetchingFresh, + isError: feed.isError, + refetch: feed.refetch, }; } diff --git a/apps/dashboard/app/(main)/insights/_components/cockpit-narrative.tsx b/apps/dashboard/app/(main)/insights/_components/cockpit-narrative.tsx new file mode 100644 index 000000000..d00404a89 --- /dev/null +++ b/apps/dashboard/app/(main)/insights/_components/cockpit-narrative.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { ArrowClockwiseIcon } from "@phosphor-icons/react/dist/ssr/ArrowClockwise"; +import { SparkleIcon } from "@phosphor-icons/react/dist/ssr/Sparkle"; +import { useAtomValue } from "jotai"; +import dayjs from "@/lib/dayjs"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import { useOrgNarrative } from "../hooks/use-org-narrative"; +import { insightsRangeAtom } from "../lib/time-range"; + +export function CockpitNarrative() { + const range = useAtomValue(insightsRangeAtom); + const { data, isLoading, isError, refetch, isFetching } = + useOrgNarrative(range); + + return ( +
+
+
+ +

+ This {rangeLabel(range)} +

+
+ {!(isLoading || isError) && + data && + data.success && + data.generatedAt && ( + + Updated {dayjs(data.generatedAt).fromNow(true)} ago + + )} +
+ +
+ {isLoading && ( +
+ + +
+ )} + + {!isLoading && isError && ( +
+

+ Couldn't generate summary +

+ +
+ )} + + {!(isLoading || isError) && data && data.success && ( +

+ {data.narrative} +

+ )} + + {!(isLoading || isError) && data && !data.success && ( +

+ Couldn't generate summary +

+ )} +
+
+ ); +} + +function rangeLabel(range: "7d" | "30d" | "90d"): string { + if (range === "7d") { + return "week"; + } + if (range === "30d") { + return "month"; + } + return "quarter"; +} diff --git a/apps/dashboard/app/(main)/insights/_components/cockpit-signals.tsx b/apps/dashboard/app/(main)/insights/_components/cockpit-signals.tsx new file mode 100644 index 000000000..41cceb9bd --- /dev/null +++ b/apps/dashboard/app/(main)/insights/_components/cockpit-signals.tsx @@ -0,0 +1,551 @@ +"use client"; + +import { useInsightsFeed } from "@/app/(main)/insights/hooks/use-insights-feed"; +import { useInsightsLocalState } from "@/app/(main)/insights/hooks/use-insights-local-state"; +import { useOrganizationsContext } from "@/components/providers/organizations-provider"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { Insight, InsightSeverity } from "@/lib/insight-types"; +import { cn } from "@/lib/utils"; +import { ArrowClockwiseIcon } from "@phosphor-icons/react/dist/ssr/ArrowClockwise"; +import { ArrowsDownUpIcon } from "@phosphor-icons/react/dist/ssr/ArrowsDownUp"; +import { CaretDownIcon } from "@phosphor-icons/react/dist/ssr/CaretDown"; +import { CheckCircleIcon } from "@phosphor-icons/react/dist/ssr/CheckCircle"; +import { FunnelIcon } from "@phosphor-icons/react/dist/ssr/Funnel"; +import { SparkleIcon } from "@phosphor-icons/react/dist/ssr/Sparkle"; +import { WarningCircleIcon } from "@phosphor-icons/react/dist/ssr/WarningCircle"; +import { XIcon } from "@phosphor-icons/react/dist/ssr/X"; +import { + type ReactElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { InsightCard } from "./insight-card"; + +type SeverityFilter = "all" | InsightSeverity; +type SortMode = "priority" | "newest" | "change"; + +const SEVERITY_OPTIONS: { value: SeverityFilter; label: string }[] = [ + { value: "all", label: "All severities" }, + { value: "critical", label: "Critical" }, + { value: "warning", label: "Warning" }, + { value: "info", label: "Info" }, +]; + +const SORT_OPTIONS: { value: SortMode; label: string }[] = [ + { value: "priority", label: "Priority" }, + { value: "newest", label: "Newest" }, + { value: "change", label: "Biggest change" }, +]; + +function sortInsights(items: Insight[], mode: SortMode): Insight[] { + const sorted = [...items]; + switch (mode) { + case "priority": + return sorted.sort((a, b) => b.priority - a.priority); + case "newest": + return sorted.sort((a, b) => { + const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return bTime - aTime; + }); + case "change": + return sorted.sort( + (a, b) => + Math.abs(b.changePercent ?? 0) - Math.abs(a.changePercent ?? 0) + ); + default: + return sorted; + } +} + +export function CockpitSignals(): ReactElement { + const { activeOrganization, activeOrganizationId } = + useOrganizationsContext(); + const orgId = activeOrganization?.id ?? activeOrganizationId ?? undefined; + + const { + insights, + isLoading, + isRefreshing, + isError, + refetch, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInsightsFeed(); + + const insightIdsForVotes = useMemo( + () => insights.map((i) => i.id), + [insights] + ); + + const { + hydrated, + dismissedIdSet, + dismissAction, + clearAllDismissedAction, + feedbackById, + setFeedbackAction, + } = useInsightsLocalState(orgId, insightIdsForVotes); + + const [severityFilter, setSeverityFilter] = useState("all"); + const [websiteFilter, setWebsiteFilter] = useState("all"); + const [sortMode, setSortMode] = useState("priority"); + const [showDismissed, setShowDismissed] = useState(false); + const [expandedId, setExpandedId] = useState(null); + + const websites = useMemo(() => { + const map = new Map(); + for (const i of insights) { + if (!map.has(i.websiteId)) { + map.set(i.websiteId, i.websiteName ?? i.websiteDomain); + } + } + return [...map.entries()].map(([id, name]) => ({ id, name })); + }, [insights]); + + const filteredInsights = useMemo(() => { + const filtered = insights.filter((i) => { + if (!showDismissed && dismissedIdSet.has(i.id)) { + return false; + } + if (severityFilter !== "all" && i.severity !== severityFilter) { + return false; + } + if (websiteFilter !== "all" && i.websiteId !== websiteFilter) { + return false; + } + return true; + }); + return sortInsights(filtered, sortMode); + }, [ + insights, + severityFilter, + websiteFilter, + dismissedIdSet, + showDismissed, + sortMode, + ]); + + const counts = useMemo( + () => ({ + total: insights.length, + critical: insights.filter((i) => i.severity === "critical").length, + warning: insights.filter((i) => i.severity === "warning").length, + info: insights.filter((i) => i.severity === "info").length, + }), + [insights] + ); + + const controlsLocked = isLoading || isFetchingNextPage; + const hasActiveFilters = severityFilter !== "all" || websiteFilter !== "all"; + + const clearFilters = () => { + setSeverityFilter("all"); + setWebsiteFilter("all"); + }; + + const selectedWebsiteName = + websiteFilter === "all" + ? "All Websites" + : (websites.find((w) => w.id === websiteFilter)?.name ?? "All Websites"); + + const selectedSeverityLabel = + SEVERITY_OPTIONS.find((o) => o.value === severityFilter)?.label ?? + "All severities"; + + const selectedSortLabel = + SORT_OPTIONS.find((o) => o.value === sortMode)?.label ?? "Priority"; + + const sectionRef = useRef(null); + const hasScrolledToHash = useRef(false); + + const scrollToHashInsight = useCallback(() => { + if (typeof window === "undefined") { + return; + } + const raw = window.location.hash.slice(1); + if (!raw.startsWith("insight-")) { + return; + } + const el = document.getElementById(raw); + if (!el) { + return; + } + requestAnimationFrame(() => { + el.scrollIntoView({ behavior: "smooth", block: "nearest" }); + const targetId = raw.replace("insight-", ""); + setExpandedId(targetId); + }); + }, []); + + useEffect(() => { + if ( + hasScrolledToHash.current || + !hydrated || + isLoading || + filteredInsights.length === 0 + ) { + return; + } + hasScrolledToHash.current = true; + scrollToHashInsight(); + }, [hydrated, isLoading, filteredInsights.length, scrollToHashInsight]); + + const showFilterBar = !(isLoading || isError) && insights.length > 0; + const visibleCount = filteredInsights.length; + + return ( +
+
+
+ +

Signals

+
+ {insights.length > 0 && ( + + {visibleCount} of {insights.length}{" "} + {insights.length === 1 ? "signal" : "signals"} + + )} +
+ + {showFilterBar && ( +
+ {websites.length > 1 && ( + + + + + + setWebsiteFilter("all")}> + All Websites + + {websites.map((w) => ( + setWebsiteFilter(w.id)} + > + {w.name} + + ))} + + + )} + + + + + + + {SEVERITY_OPTIONS.map((opt) => { + const count = + opt.value === "all" ? counts.total : counts[opt.value]; + if (opt.value !== "all" && count === 0) { + return null; + } + return ( + setSeverityFilter(opt.value)} + > + {opt.label} + + {count} + + + ); + })} + + + + + + + + + {SORT_OPTIONS.map((opt) => ( + setSortMode(opt.value)} + > + {opt.label} + + ))} + + + + {dismissedIdSet.size > 0 && ( + + )} + + {hasActiveFilters && ( + + )} +
+ )} + + {isLoading && ( + + )} + + {!(isLoading || isError) && isRefreshing && ( + + )} + + {!isLoading && isError && } + + {!(isLoading || isError) && ( + <> + {insights.length === 0 && !isRefreshing && } + + {filteredInsights.length > 0 && + filteredInsights.map((insight) => ( + dismissAction(insight.id)} + onFeedbackAction={(vote) => setFeedbackAction(insight.id, vote)} + onToggleAction={() => + setExpandedId((prev) => + prev === insight.id ? null : insight.id + ) + } + /> + ))} + + {insights.length > 0 && filteredInsights.length === 0 && ( + 0 + ? () => setShowDismissed(true) + : undefined + } + /> + )} + + {hasNextPage && ( +
+ +
+ )} + + {hydrated && dismissedIdSet.size > 0 && ( +
+ +
+ )} + + )} +
+ ); +} + +function InsightsFetchStatusRow({ + title, + description, + variant, +}: { + title: string; + description: string; + variant: "initial" | "refresh"; +}) { + return ( +
+ {variant === "refresh" ? ( + + ) : ( + + )} +
+

+ {title} +

+

{description}

+
+
+ ); +} + +function ErrorState({ onRetryAction }: { onRetryAction: () => void }) { + return ( +
+
+ +
+
+

Couldn't load insights

+

+ AI analysis timed out or failed. Try again. +

+
+ +
+ ); +} + +function AllHealthyState() { + return ( +
+
+ +
+
+

All systems healthy

+

+ No actionable insights detected across your websites this week +

+
+
+ ); +} + +function NoMatchState({ + onClearAction, + onShowDismissedAction, +}: { + onClearAction: () => void; + onShowDismissedAction?: () => void; +}) { + return ( +
+
+ +
+
+

No matching insights

+

+ Try adjusting your filters +

+
+
+ + {onShowDismissedAction && ( + + )} +
+
+ ); +} diff --git a/apps/dashboard/app/(main)/insights/_components/insight-card.tsx b/apps/dashboard/app/(main)/insights/_components/insight-card.tsx index dff8b32c8..71fa12524 100644 --- a/apps/dashboard/app/(main)/insights/_components/insight-card.tsx +++ b/apps/dashboard/app/(main)/insights/_components/insight-card.tsx @@ -166,6 +166,7 @@ export interface InsightCardProps { onDismissAction?: () => void; onFeedbackAction?: (vote: InsightFeedbackVote | null) => void; onToggleAction: () => void; + variant?: "full" | "compact"; } export function InsightCard({ @@ -175,25 +176,36 @@ export function InsightCard({ onDismissAction, feedbackVote, onFeedbackAction, + variant = "full", }: InsightCardProps) { + const isCompact = variant === "compact"; const typeStyle = TYPE_STYLES[insight.type]; const sentimentStyle = SENTIMENT_STYLES[insight.sentiment]; const freshnessLine = formatInsightFreshness(insight); const agentHref = useMemo(() => { + if (isCompact) { + return ""; + } const chatId = crypto.randomUUID(); const prompt = encodeURIComponent(buildDiagnosticPrompt(insight)); return `/websites/${insight.websiteId}/agent/${chatId}?prompt=${prompt}`; - }, [insight]); + }, [isCompact, insight]); - const pathHint = useMemo(() => extractInsightPathHint(insight), [insight]); + const pathHint = useMemo( + () => (isCompact ? null : extractInsightPathHint(insight)), + [isCompact, insight] + ); const analyticsHref = useMemo(() => { + if (isCompact) { + return insight.link; + } if (pathHint) { return `/websites/${insight.websiteId}/events/stream?path=${encodeURIComponent(pathHint)}`; } return insight.link; - }, [insight.websiteId, insight.link, pathHint]); + }, [isCompact, insight.websiteId, insight.link, pathHint]); const analyticsLabel = pathHint ? "View events" : "Overview"; @@ -222,14 +234,17 @@ export function InsightCard({ return (
{/* biome-ignore lint/a11y/useSemanticElements: full-row toggle cannot use
-
- e.stopPropagation()} - > - Ask agent - - - e.stopPropagation()} - > - {analyticsLabel} - - - - - - - - { - e.stopPropagation(); - copyAgentPromptAction(); - }} - > - - Copy prompt - - - - Copy link - - - + {!isCompact && ( +
+ e.stopPropagation()} + > + Ask agent + + + e.stopPropagation()} + > + {analyticsLabel} + -
- {freshnessLine && ( - - {freshnessLine} - - )} - {onFeedbackAction && ( - <> + + - - - )} + + Copy prompt + + + + Copy link + + + + +
+ {freshnessLine && ( + + {freshnessLine} + + )} + {onFeedbackAction && ( + <> + + + + )} +
-
+ )}
)} @@ -446,7 +463,7 @@ export function InsightCard({ export function InsightCardSkeleton() { return ( -
+
diff --git a/apps/dashboard/app/(main)/insights/_components/insights-page-content.tsx b/apps/dashboard/app/(main)/insights/_components/insights-page-content.tsx index 5ea00ad3c..d576f82a5 100644 --- a/apps/dashboard/app/(main)/insights/_components/insights-page-content.tsx +++ b/apps/dashboard/app/(main)/insights/_components/insights-page-content.tsx @@ -1,9 +1,33 @@ "use client"; +import { ArrowClockwiseIcon } from "@phosphor-icons/react/dist/ssr/ArrowClockwise"; +import { CaretDownIcon } from "@phosphor-icons/react/dist/ssr/CaretDown"; +import { ChartLineIcon } from "@phosphor-icons/react/dist/ssr/ChartLine"; +import { CursorIcon } from "@phosphor-icons/react/dist/ssr/Cursor"; +import { GlobeIcon } from "@phosphor-icons/react/dist/ssr/Globe"; +import { SparkleIcon } from "@phosphor-icons/react/dist/ssr/Sparkle"; +import { TimerIcon } from "@phosphor-icons/react/dist/ssr/Timer"; +import { TrashIcon } from "@phosphor-icons/react/dist/ssr/Trash"; +import { UsersIcon } from "@phosphor-icons/react/dist/ssr/Users"; +import type { ColumnDef } from "@tanstack/react-table"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useAtom, useAtomValue } from "jotai"; +import { atomWithStorage } from "jotai/utils"; +import { useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; import { useInsightsFeed } from "@/app/(main)/insights/hooks/use-insights-feed"; import { useInsightsLocalState } from "@/app/(main)/insights/hooks/use-insights-local-state"; import { PageHeader } from "@/app/(main)/websites/_components/page-header"; +import { FaviconImage } from "@/components/analytics/favicon-image"; +import { StatCard } from "@/components/analytics/stat-card"; +import { EmptyState } from "@/components/empty-state"; import { useOrganizationsContext } from "@/components/providers/organizations-provider"; +import { DataTable } from "@/components/table/data-table"; +import { + createGeoColumns, + createPageColumns, + createReferrerColumns, +} from "@/components/table/rows"; import { AlertDialog, AlertDialogAction, @@ -19,67 +43,125 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { useBatchDynamicQuery } from "@/hooks/use-dynamic-query"; +import { useWebsites } from "@/hooks/use-websites"; +import dayjs from "@/lib/dayjs"; +import { formatNumber } from "@/lib/formatters"; import { clearInsightsHistory, INSIGHT_QUERY_KEYS, type InsightsAiResponse, type InsightsHistoryPage, } from "@/lib/insight-api"; -import type { Insight, InsightSeverity } from "@/lib/insight-types"; import { orpc } from "@/lib/orpc"; import { cn } from "@/lib/utils"; -import { ArrowClockwiseIcon } from "@phosphor-icons/react"; -import { ArrowsDownUpIcon } from "@phosphor-icons/react"; -import { CaretDownIcon } from "@phosphor-icons/react"; -import { CheckCircleIcon } from "@phosphor-icons/react"; -import { FunnelIcon } from "@phosphor-icons/react"; -import { SparkleIcon } from "@phosphor-icons/react"; -import { TrashIcon } from "@phosphor-icons/react"; -import { WarningCircleIcon } from "@phosphor-icons/react"; -import { XIcon } from "@phosphor-icons/react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; -import { InsightCard } from "./insight-card"; - -type SeverityFilter = "all" | InsightSeverity; -type SortMode = "priority" | "newest" | "change"; - -const SEVERITY_OPTIONS: { value: SeverityFilter; label: string }[] = [ - { value: "all", label: "All severities" }, - { value: "critical", label: "Critical" }, - { value: "warning", label: "Warning" }, - { value: "info", label: "Info" }, -]; - -const SORT_OPTIONS: { value: SortMode; label: string }[] = [ - { value: "priority", label: "Priority" }, - { value: "newest", label: "Newest" }, - { value: "change", label: "Biggest change" }, -]; - -function sortInsights(items: Insight[], mode: SortMode): Insight[] { - const sorted = [...items]; - switch (mode) { - case "priority": - return sorted.sort((a, b) => b.priority - a.priority); - case "newest": - return sorted.sort((a, b) => { - const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0; - const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0; - return bTime - aTime; - }); - case "change": - return sorted.sort( - (a, b) => - Math.abs(b.changePercent ?? 0) - Math.abs(a.changePercent ?? 0) - ); - default: - return sorted; +import { insightsRangeAtom } from "../lib/time-range"; +import { CockpitNarrative } from "./cockpit-narrative"; +import { CockpitSignals } from "./cockpit-signals"; +import { TimeRangeSelector } from "./time-range-selector"; + +const insightsFocusSiteAtom = atomWithStorage( + "insights.focus-site", + null +); + +function rangeToDateRange(range: "7d" | "30d" | "90d") { + const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; + return { + start_date: dayjs() + .subtract(days - 1, "day") + .format("YYYY-MM-DD"), + end_date: dayjs().format("YYYY-MM-DD"), + granularity: "daily" as const, + }; +} + +interface SummaryRow { + bounce_rate?: number; + median_session_duration?: number; + pageviews?: number; + sessions?: number; + unique_visitors?: number; +} + +function clampBounceRate(v: unknown): number { + const n = Number(v ?? 0); + if (Number.isNaN(n)) { + return 0; } + return Math.max(0, Math.min(100, n)); +} + +function formatDuration(value: number): string { + if (!value || value < 60) { + return `${Math.round(value || 0)}s`; + } + const minutes = Math.floor(value / 60); + const seconds = Math.round(value % 60); + return `${minutes}m ${seconds}s`; +} + +interface FocusSitePickerProps { + onChange: (id: string) => void; + value: string | null; + websites: + | { + domain: string; + id: string; + name: string | null; + }[] + | undefined; +} + +function FocusSitePicker({ websites, value, onChange }: FocusSitePickerProps) { + const list = websites ?? []; + const selected = list.find((w) => w.id === value) ?? list[0]; + if (!(selected && list.length > 1)) { + return null; + } + return ( + + + + + + {list.map((site) => ( + onChange(site.id)} + > + + + {site.name ?? site.domain} + + + ))} + + + ); } export function InsightsPageContent() { @@ -88,43 +170,130 @@ export function InsightsPageContent() { useOrganizationsContext(); const orgId = activeOrganization?.id ?? activeOrganizationId ?? undefined; + const { insights, isLoading, isRefreshing, refetch } = useInsightsFeed(); + + const range = useAtomValue(insightsRangeAtom); + const { websites, isLoading: websitesLoading } = useWebsites(); + const [focusSiteId, setFocusSiteId] = useAtom(insightsFocusSiteAtom); + + const effectiveSiteId = useMemo(() => { + if (!websites || websites.length === 0) { + return ""; + } + if (focusSiteId && websites.some((w) => w.id === focusSiteId)) { + return focusSiteId; + } + return websites[0].id; + }, [focusSiteId, websites]); + + const dateRange = useMemo(() => rangeToDateRange(range), [range]); + + const queries = useMemo( + () => [ + { + id: "cockpit-summary", + parameters: ["summary_metrics", "events_by_date"], + limit: 100, + granularity: dateRange.granularity, + }, + { + id: "cockpit-pages", + parameters: ["top_pages"], + limit: 8, + granularity: dateRange.granularity, + }, + { + id: "cockpit-referrers", + parameters: ["top_referrers"], + limit: 8, + granularity: dateRange.granularity, + }, + { + id: "cockpit-geo", + parameters: ["country"], + limit: 8, + granularity: dateRange.granularity, + }, + ], + [dateRange.granularity] + ); + const { - insights, - isLoading, - isRefreshing, - isError, - refetch, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - } = useInsightsFeed(); + getDataForQuery, + isLoading: cockpitLoading, + refetch: refetchCockpit, + } = useBatchDynamicQuery(effectiveSiteId, dateRange, queries, { + enabled: Boolean(effectiveSiteId), + }); + + const summary = (getDataForQuery("cockpit-summary", "summary_metrics") ?? + [])[0] as SummaryRow | undefined; + const eventsByDate = (getDataForQuery("cockpit-summary", "events_by_date") ?? + []) as Record[]; + const topPages = (getDataForQuery("cockpit-pages", "top_pages") ?? []) as { + name: string; + }[]; + const topReferrers = (getDataForQuery("cockpit-referrers", "top_referrers") ?? + []) as { name: string }[]; + const topCountries = (getDataForQuery("cockpit-geo", "country") ?? []) as { + name: string; + }[]; + + const miniCharts = useMemo(() => { + const build = (field: string, transform?: (value: number) => number) => + eventsByDate.map((row) => ({ + date: String(row.date ?? "").slice(0, 10), + value: transform + ? transform(Number(row[field] ?? 0)) + : Number(row[field] ?? 0), + })); + return { + visitors: build("visitors"), + sessions: build("sessions"), + pageviews: build("pageviews"), + bounce: build("bounce_rate", clampBounceRate), + duration: build("median_session_duration"), + }; + }, [eventsByDate]); + + const pageColumns = useMemo( + () => + createPageColumns() as unknown as ColumnDef<{ name: string }, unknown>[], + [] + ); + const referrerColumns = useMemo( + () => + createReferrerColumns() as unknown as ColumnDef< + { name: string }, + unknown + >[], + [] + ); + const countryColumns = useMemo( + () => + createGeoColumns({ type: "country" }) as unknown as ColumnDef< + { name: string }, + unknown + >[], + [] + ); const insightIdsForVotes = useMemo( () => insights.map((i) => i.id), [insights] ); - const { - hydrated, - dismissedIdSet, - dismissAction, - clearAllDismissedAction, - feedbackById, - setFeedbackAction, - } = useInsightsLocalState(orgId, insightIdsForVotes); - - const [severityFilter, setSeverityFilter] = useState("all"); - const [websiteFilter, setWebsiteFilter] = useState("all"); - const [sortMode, setSortMode] = useState("priority"); - const [showDismissed, setShowDismissed] = useState(false); - const [expandedId, setExpandedId] = useState(null); + const { clearAllDismissedAction } = useInsightsLocalState( + orgId, + insightIdsForVotes + ); + const [clearDialogOpen, setClearDialogOpen] = useState(false); const clearInsightsMutation = useMutation({ mutationFn: () => clearInsightsHistory(orgId ?? ""), onSuccess: async (data) => { setClearDialogOpen(false); - setExpandedId(null); clearAllDismissedAction(); if (orgId) { const emptyAi: InsightsAiResponse = { @@ -145,10 +314,6 @@ export function InsightsPageContent() { pages: [emptyHistoryPage], pageParams: [0], }); - queryClient.setQueryData( - [INSIGHT_QUERY_KEYS.history, orgId], - emptyHistoryPage - ); await queryClient.invalidateQueries({ queryKey: orpc.insights.getVotes.key(), }); @@ -166,159 +331,46 @@ export function InsightsPageContent() { }, }); - const websites = useMemo(() => { - const map = new Map(); - for (const i of insights) { - if (!map.has(i.websiteId)) { - map.set(i.websiteId, i.websiteName ?? i.websiteDomain); - } - } - return [...map.entries()].map(([id, name]) => ({ id, name })); - }, [insights]); - - const filteredInsights = useMemo(() => { - const filtered = insights.filter((i) => { - if (!showDismissed && dismissedIdSet.has(i.id)) { - return false; - } - if (severityFilter !== "all" && i.severity !== severityFilter) { - return false; - } - if (websiteFilter !== "all" && i.websiteId !== websiteFilter) { - return false; - } - return true; - }); - return sortInsights(filtered, sortMode); - }, [ - insights, - severityFilter, - websiteFilter, - dismissedIdSet, - showDismissed, - sortMode, - ]); - - const counts = useMemo( - () => ({ - total: insights.length, - critical: insights.filter((i) => i.severity === "critical").length, - warning: insights.filter((i) => i.severity === "warning").length, - info: insights.filter((i) => i.severity === "info").length, - }), - [insights] - ); - - const controlsLocked = - isLoading || clearInsightsMutation.isPending || isFetchingNextPage; - const hasActiveFilters = severityFilter !== "all" || websiteFilter !== "all"; - - const clearFilters = () => { - setSeverityFilter("all"); - setWebsiteFilter("all"); - }; - - const selectedWebsiteName = - websiteFilter === "all" - ? "All Websites" - : (websites.find((w) => w.id === websiteFilter)?.name ?? "All Websites"); + const handleRefreshAll = useCallback(() => { + refetch(); + refetchCockpit(); + }, [refetch, refetchCockpit]); - const selectedSeverityLabel = - SEVERITY_OPTIONS.find((o) => o.value === severityFilter)?.label ?? - "All severities"; - - const selectedSortLabel = - SORT_OPTIONS.find((o) => o.value === sortMode)?.label ?? "Priority"; - - const scrollRef = useRef(null); - const hasScrolledToHash = useRef(false); - - const scrollToHashInsight = useCallback(() => { - if (typeof window === "undefined") { - return; - } - const raw = window.location.hash.slice(1); - if (!raw.startsWith("insight-")) { - return; - } - const el = document.getElementById(raw); - const container = scrollRef.current; - if (!(el && container)) { - return; - } - requestAnimationFrame(() => { - const containerRect = container.getBoundingClientRect(); - const elRect = el.getBoundingClientRect(); - const nextTop = container.scrollTop + (elRect.top - containerRect.top); - container.scrollTo({ behavior: "smooth", top: nextTop }); - const targetId = raw.replace("insight-", ""); - setExpandedId(targetId); - }); - }, []); - - useEffect(() => { - if ( - hasScrolledToHash.current || - !hydrated || - isLoading || - filteredInsights.length === 0 - ) { - return; - } - hasScrolledToHash.current = true; - scrollToHashInsight(); - }, [hydrated, isLoading, filteredInsights.length, scrollToHashInsight]); - - const showFilterBar = !(isLoading || isError) && insights.length > 0; + const hasNoWebsites = + !websitesLoading && websites !== undefined && websites.length === 0; return ( <> -
+
} right={
- {websites.length > 1 && ( - - - - - - setWebsiteFilter("all")}> - All Websites - - - {websites.map((w) => ( - setWebsiteFilter(w.id)} - > - {w.name} - - ))} - - - )} + + - - - {SEVERITY_OPTIONS.map((opt) => { - const count = - opt.value === "all" ? counts.total : counts[opt.value]; - if (opt.value !== "all" && count === 0) { - return null; - } - return ( - setSeverityFilter(opt.value)} - > - {opt.label} - - {count} - - - ); - })} - - - - - - - - - {SORT_OPTIONS.map((opt) => ( - setSortMode(opt.value)} - > - {opt.label} - - ))} - - - - {dismissedIdSet.size > 0 && ( - - )} - - {hasActiveFilters && ( - - )} -
- )} + {hasNoWebsites ? ( + + ) : ( +
+ + +
+ + + + `${v.toFixed(1)}%`} + icon={CursorIcon} + id="cockpit-bounce" + invertTrend + isLoading={cockpitLoading} + showChart + title="Bounce rate" + value={ + summary?.bounce_rate == null + ? "0%" + : `${clampBounceRate(summary.bounce_rate).toFixed(1)}%` + } + /> + +
- {isLoading && ( - - )} +
+ + +
- {!(isLoading || isError) && isRefreshing && ( - - )} + - {!isLoading && isError && } - - {!(isLoading || isError) && ( - <> - {insights.length === 0 && !isRefreshing && } - - {filteredInsights.length > 0 && - filteredInsights.map((insight) => ( - dismissAction(insight.id)} - onFeedbackAction={(vote) => - setFeedbackAction(insight.id, vote) - } - onToggleAction={() => - setExpandedId((prev) => - prev === insight.id ? null : insight.id - ) - } - /> - ))} - - {insights.length > 0 && filteredInsights.length === 0 && ( - 0 - ? () => setShowDismissed(true) - : undefined - } - /> - )} - - {hasNextPage && ( -
- -
- )} - - {hydrated && dismissedIdSet.size > 0 && ( -
- -
- )} - + +
)}
@@ -549,112 +525,19 @@ export function InsightsPageContent() { ); } -function InsightsFetchStatusRow({ - title, - description, - variant, -}: { - title: string; - description: string; - variant: "initial" | "refresh"; -}) { - return ( -
- {variant === "refresh" ? ( - - ) : ( - - )} -
-

- {title} -

-

{description}

-
-
- ); -} - -function ErrorState({ onRetryAction }: { onRetryAction: () => void }) { - return ( -
-
- -
-
-

Couldn't load insights

-

- AI analysis timed out or failed. Try again. -

-
- -
- ); -} - -function AllHealthyState() { - return ( -
-
- -
-
-

All systems healthy

-

- No actionable insights detected across your websites this week -

-
-
- ); -} - -function NoMatchState({ - onClearAction, - onShowDismissedAction, -}: { - onClearAction: () => void; - onShowDismissedAction?: () => void; -}) { +function EmptyOrgState() { return ( -
-
- -
-
-

No matching insights

-

- Try adjusting your filters -

-
-
- - {onShowDismissedAction && ( - - )} -
-
+ { + window.location.href = "/websites"; + }, + }} + description="Add a website to see insights across your organization." + icon={} + title="No websites yet" + variant="minimal" + /> ); } diff --git a/apps/dashboard/app/(main)/insights/_components/time-range-selector.tsx b/apps/dashboard/app/(main)/insights/_components/time-range-selector.tsx new file mode 100644 index 000000000..781ba2153 --- /dev/null +++ b/apps/dashboard/app/(main)/insights/_components/time-range-selector.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useAtom } from "jotai"; +import { SegmentedControl } from "@/components/ui/segmented-control"; +import { + insightsRangeAtom, + TIME_RANGE_SHORT_LABELS, + TIME_RANGES, + type TimeRange, +} from "../lib/time-range"; + +const OPTIONS = TIME_RANGES.map((value) => ({ + value, + label: TIME_RANGE_SHORT_LABELS[value], +})); + +export function TimeRangeSelector() { + const [range, setRange] = useAtom(insightsRangeAtom); + + return ( + + onValueChangeAction={setRange} + options={OPTIONS} + value={range} + /> + ); +} diff --git a/apps/dashboard/app/(main)/insights/hooks/use-insights-feed.ts b/apps/dashboard/app/(main)/insights/hooks/use-insights-feed.ts index 7a5b32d50..d2548f4b1 100644 --- a/apps/dashboard/app/(main)/insights/hooks/use-insights-feed.ts +++ b/apps/dashboard/app/(main)/insights/hooks/use-insights-feed.ts @@ -136,12 +136,15 @@ export function useInsightsFeed() { const isRefreshing = isFetching && !isInitialLoading; + const isFetchingFresh = mergedInsights.length > 0 && aiQuery.isFetching; + return { insights: mergedInsights, source: aiQuery.data?.source ?? null, isLoading: isInitialLoading, isRefreshing, isFetching, + isFetchingFresh, isError, refetch: refetchAll, fetchNextPage: historyInfinite.fetchNextPage, diff --git a/apps/dashboard/app/(main)/insights/hooks/use-org-narrative.ts b/apps/dashboard/app/(main)/insights/hooks/use-org-narrative.ts new file mode 100644 index 000000000..277db1225 --- /dev/null +++ b/apps/dashboard/app/(main)/insights/hooks/use-org-narrative.ts @@ -0,0 +1,28 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useOrganizationsContext } from "@/components/providers/organizations-provider"; +import { + fetchInsightsOrgNarrative, + INSIGHT_QUERY_KEYS, +} from "@/lib/insight-api"; +import type { TimeRange } from "../lib/time-range"; + +export function useOrgNarrative(range: TimeRange) { + const { activeOrganization, activeOrganizationId } = + useOrganizationsContext(); + const orgId = activeOrganization?.id ?? activeOrganizationId ?? undefined; + + return useQuery({ + queryKey: [INSIGHT_QUERY_KEYS.orgNarrative, orgId, range], + queryFn: () => { + if (!orgId) { + throw new Error("No organization"); + } + return fetchInsightsOrgNarrative(orgId, range); + }, + enabled: !!orgId, + staleTime: 60 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/apps/dashboard/app/(main)/insights/lib/time-range.ts b/apps/dashboard/app/(main)/insights/lib/time-range.ts new file mode 100644 index 000000000..516653690 --- /dev/null +++ b/apps/dashboard/app/(main)/insights/lib/time-range.ts @@ -0,0 +1,22 @@ +import { atomWithStorage } from "jotai/utils"; + +export type TimeRange = "7d" | "30d" | "90d"; + +export const TIME_RANGES: TimeRange[] = ["7d", "30d", "90d"]; + +export const TIME_RANGE_LABELS: Record = { + "7d": "Last 7 days", + "30d": "Last 30 days", + "90d": "Last 90 days", +}; + +export const TIME_RANGE_SHORT_LABELS: Record = { + "7d": "7d", + "30d": "30d", + "90d": "90d", +}; + +export const insightsRangeAtom = atomWithStorage( + "insights.range", + "7d" +); diff --git a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-command-menu.tsx b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-command-menu.tsx new file mode 100644 index 000000000..bf9cfced0 --- /dev/null +++ b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-command-menu.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { + ChartBarIcon, + ClipboardTextIcon, + CompassIcon, + FileTextIcon, + FunnelIcon, + LightningIcon, + MagnifyingGlassIcon, + WarningIcon, +} from "@phosphor-icons/react"; +import { + Popover, + PopoverAnchor, + PopoverContent, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import type { AgentCommand } from "./agent-commands"; + +const COMMAND_ICONS: Record = { + "/analyze": MagnifyingGlassIcon, + "/sources": CompassIcon, + "/funnel": FunnelIcon, + "/pages": FileTextIcon, + "/live": LightningIcon, + "/anomalies": WarningIcon, + "/compare": ChartBarIcon, + "/report": ClipboardTextIcon, +}; + +function getCommandIcon(command: string) { + return COMMAND_ICONS[command] ?? MagnifyingGlassIcon; +} + +interface AgentCommandMenuProps { + anchor: React.ReactNode; + commands: readonly AgentCommand[]; + onDismiss: () => void; + onHover: (index: number) => void; + onSelect: (command: AgentCommand) => void; + open: boolean; + selectedIndex: number; +} + +export function AgentCommandMenu({ + anchor, + commands, + onDismiss, + onHover, + onSelect, + open, + selectedIndex, +}: AgentCommandMenuProps) { + return ( + { + if (!next) { + onDismiss(); + } + }} + open={open} + > + {anchor} + e.preventDefault()} + side="top" + sideOffset={8} + > +
+ Commands +
+
    + {commands.map((cmd, idx) => { + const Icon = getCommandIcon(cmd.command); + const isSelected = idx === selectedIndex; + return ( +
  • + +
  • + ); + })} +
+
+
+ ); +} diff --git a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-commands.ts b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-commands.ts new file mode 100644 index 000000000..517275778 --- /dev/null +++ b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-commands.ts @@ -0,0 +1,97 @@ +export interface AgentCommand { + command: string; + description: string; + id: string; + keywords: readonly string[]; + prompt: string; + title: string; +} + +export const AGENT_COMMANDS: readonly AgentCommand[] = [ + { + id: "analyze-traffic", + command: "/analyze", + title: "Analyze traffic patterns", + description: "Deep dive into recent traffic trends", + prompt: + "Analyze my traffic patterns over the last 7 days. Call out any notable spikes, drops, or shifts in source mix, and tell me what's driving the biggest changes.", + keywords: ["analyze", "traffic", "patterns", "trends", "visitors"], + }, + { + id: "analyze-sources", + command: "/sources", + title: "Break down traffic sources", + description: "Where's traffic coming from right now", + prompt: + "Break down my traffic sources for the last 7 days — referrers, search, direct, social — and flag any sources that are over- or under-performing vs the prior period.", + keywords: ["sources", "referrers", "channels", "medium", "acquisition"], + }, + { + id: "analyze-funnel", + command: "/funnel", + title: "Inspect my funnels", + description: "Look for drop-offs and weak steps", + prompt: + "List my funnels and walk through each one. Point out the steps with the biggest drop-offs and suggest what to investigate next.", + keywords: ["funnel", "funnels", "conversion", "drop-off", "goals"], + }, + { + id: "top-pages", + command: "/pages", + title: "Top pages", + description: "Most-visited pages with context", + prompt: + "Show me my top 10 pages by pageviews over the last 7 days, including bounce rate and average time on page. Highlight any pages that stand out.", + keywords: ["pages", "top", "popular", "content", "views"], + }, + { + id: "live", + command: "/live", + title: "What's happening now", + description: "Live sessions and recent activity", + prompt: + "Tell me what's happening on the site right now — active sessions, most-viewed pages in the last hour, and any recent events worth knowing about.", + keywords: ["live", "now", "active", "realtime", "sessions"], + }, + { + id: "anomalies", + command: "/anomalies", + title: "Find anomalies", + description: "Detect unusual patterns in the data", + prompt: + "Scan my analytics for anomalies over the last 14 days — unusual spikes, drops, or new traffic sources — and rank them by how concerning they are.", + keywords: ["anomalies", "unusual", "spikes", "drops", "alerts"], + }, + { + id: "compare", + command: "/compare", + title: "Compare periods", + description: "Last 7 days vs prior 7 days", + prompt: + "Compare my key metrics (visitors, sessions, pageviews, bounce rate, conversion) between the last 7 days and the prior 7 days. Explain what changed and why.", + keywords: ["compare", "periods", "before", "after", "change", "delta"], + }, + { + id: "report", + command: "/report", + title: "Weekly report", + description: "Executive summary of the last week", + prompt: + "Generate a concise weekly analytics report: top-line metrics, biggest wins, biggest concerns, and recommended actions for next week.", + keywords: ["report", "weekly", "summary", "executive", "overview"], + }, +] as const; + +export function filterCommands(query: string): readonly AgentCommand[] { + const normalized = query.toLowerCase().trim(); + if (!normalized) { + return AGENT_COMMANDS; + } + return AGENT_COMMANDS.filter( + (cmd) => + cmd.command.toLowerCase().includes(normalized) || + cmd.title.toLowerCase().includes(normalized) || + cmd.description.toLowerCase().includes(normalized) || + cmd.keywords.some((kw) => kw.includes(normalized)) + ); +} diff --git a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-input.tsx b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-input.tsx index 399f6a73f..0878c5df6 100644 --- a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-input.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-input.tsx @@ -2,23 +2,25 @@ import { BrainIcon, - CaretDownIcon, ClockCountdownIcon, PaperPlaneRightIcon, StopIcon, XIcon, } from "@phosphor-icons/react"; import { useAtom } from "jotai"; -import { Button } from "@/components/ui/button"; +import { useMemo, useState } from "react"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; + UnicodeSpinner, + useRandomThinkingVariant, +} from "@/components/ai-elements/unicode-spinner"; +import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { useChat, usePendingQueue } from "@/contexts/chat-context"; import { cn } from "@/lib/utils"; import { @@ -27,6 +29,8 @@ import { agentInputAtom, agentThinkingAtom, } from "./agent-atoms"; +import { AgentCommandMenu } from "./agent-command-menu"; +import { type AgentCommand, filterCommands } from "./agent-commands"; import { useEnterSubmit } from "./hooks/use-enter-submit"; export function AgentInput() { @@ -35,6 +39,23 @@ export function AgentInput() { const isLoading = status === "streaming" || status === "submitted"; const [input, setInput] = useAtom(agentInputAtom); const { formRef, onKeyDown } = useEnterSubmit(); + const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); + const [commandsDismissed, setCommandsDismissed] = useState(false); + + const filteredCommands = useMemo(() => { + if (!input.startsWith("/")) { + return []; + } + const query = input.slice(1); + return filterCommands(query); + }, [input]); + + const showCommands = + !(commandsDismissed || isLoading) && filteredCommands.length > 0; + const safeCommandIndex = + filteredCommands.length === 0 + ? 0 + : Math.min(selectedCommandIndex, filteredCommands.length - 1); const handleSubmit = (e?: React.FormEvent) => { e?.preventDefault(); @@ -43,6 +64,67 @@ export function AgentInput() { } sendMessage({ text: input.trim() }); setInput(""); + setCommandsDismissed(false); + }; + + const selectCommand = (command: AgentCommand) => { + setInput(command.prompt); + setSelectedCommandIndex(0); + setCommandsDismissed(true); + }; + + const handleTextareaKeyDown = ( + event: React.KeyboardEvent + ) => { + if (showCommands) { + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedCommandIndex((prev) => (prev + 1) % filteredCommands.length); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedCommandIndex( + (prev) => + (prev - 1 + filteredCommands.length) % filteredCommands.length + ); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + setCommandsDismissed(true); + return; + } + if ( + event.key === "Enter" && + !event.shiftKey && + !event.nativeEvent.isComposing + ) { + event.preventDefault(); + const target = filteredCommands[safeCommandIndex]; + if (target) { + selectCommand(target); + } + return; + } + if (event.key === "Tab") { + event.preventDefault(); + const target = filteredCommands[safeCommandIndex]; + if (target) { + selectCommand(target); + } + return; + } + } + onKeyDown(event); + }; + + const handleInputChange = (value: string) => { + setInput(value); + if (!value.startsWith("/")) { + setCommandsDismissed(false); + } + setSelectedCommandIndex(0); }; return ( @@ -60,59 +142,69 @@ export function AgentInput() { /> ) : null} -
-