feat(insights): redesign insights as AI cockpit (DAT-100)#402
feat(insights): redesign insights as AI cockpit (DAT-100)#402izadoesdev merged 49 commits intostagingfrom
Conversation
Avoids crypto.randomUUID() and prompt building on every render of compact rows where the action row never mounts. DAT-100
Removes local InsightRow and buildDiagnosticPrompt duplication in smart-insights-section.tsx. DAT-100
InsightCard carries its own border-b per row. Keeping divide-y on the scroll container rendered visible double lines in the home sidebar. DAT-100
useSmartInsights becomes a thin wrapper that slices to 20. Both pages now share the same underlying queries and cache keys. DAT-100
The history query key is no longer read after the hook unification in the previous commit. Only the historyInfinite + ai keys need optimistic empty payloads. DAT-100
TDD first half of the org KPI aggregation. Implementation lands in the next commit. DAT-100
…ng phrases Replace phosphor icon spinners in the agent chat with a UnicodeSpinner component driving braille/CLI animations (dots, dots2, orbit, breathe, snake, columns, helix, diagswipe, fillsweep, line). Frames pre-baked from gunnargray-dev/unicode-animations (MIT). Tool step, streaming indicator, reasoning trigger, and generating hint each roll a random variant per turn via useRandomThinkingVariant, and the streaming/reasoning labels cycle bunny-themed phrases via useThinkingPhrase so every new turn feels distinct. Font is forced to a system monospace stack because LT Superior Mono lacks the Unicode Braille block and silently falls back to a proportional face.
Pure aggregateKpiSeries passes 8 test cases covering sum, bounce percent, visitor-weighted LCP, zero-fill, and signed change. fetchOrgKpis runs a single ClickHouse query over analytics.events joined per day per website, then fans out to 5 metrics. Vitals LCP and errors are stubbed to 0 with follow-up TODOs. DAT-100
The secondary variant was accidentally removed in 6b31774, leaving every consumer (Downgrade button in the pricing table, plus many other Button variant="secondary" sites) rendering with no background. Restore it using the same token stack (bg-secondary / hover:bg-secondary-brighter) that was there before.
Remove the insights flag from the Insights home nav item and drop the WIP tag + agent flag from the AI Agent website nav item so both surface to all users. Also consolidates the phosphor icon imports into a single line.
- session_stats.sessions now uses countIf(page_count >= 1) to match the canonical per-site bounce formula in summary.ts - page_agg now filters session_id != '' consistent with session_agg DAT-100
mergeDimensionRows groups by key, sums current/previous, computes signed change, sorts desc, and slices. fetchOrgDimensions runs a two-window ClickHouse query over analytics.events for country/page/ referrer dimensions. DAT-100
Page kind now applies the same trimRight(path(path), '/') normalization used by per-site top_pages queries. Referrer kind uses the canonical domain-collapsing expression from expressions.ts. Without these fixes, the org-wide dimension tile would show raw high-cardinality URL strings instead of useful groupings. Also makes mergeDimensionRows' limit parameter optional (default 5) per the module spec. DAT-100
Previous fix commit dropped the root path from the ranking to avoid homepage traffic dominating; that is actually the signal we want to surface on the cockpit. The empty-string filter is enough. DAT-100
…down Replace the thinking-effort dropdown with a single button that cycles off → low → medium → high on each click. Description text moves to a hover Tooltip so no info is lost. Drops CaretDownIcon and all DropdownMenu* imports.
Once streaming finishes and the reasoning block collapses to "Thought for Xs", the decorative brain icon added visual noise without conveying anything the label didn't already say.
packages/rpc cannot import from apps/api, so the B4 RPC procedure needs these helpers in the rpc package. Inlines the two Expressions constants used by org-dimensions to avoid cross-package coupling on apps/api's query builder internals. DAT-100
sessionProcedure + org guard + 5-min cacheable wrapper around fetchOrgKpis. Lists org websites via Drizzle, returns zero-filled summaries when the org has no sites. Site health is stubbed to 'healthy' with a follow-up TODO. DAT-100
Mirrors orgSummary's shape: sessionProcedure + org guard + 5-min cacheable. Calls fetchOrgDimensions in parallel for country / page / referrer kinds. Limit defaults to 5, capped at 20. Also extracts listOrgWebsites as a top-level helper so both orgSummary and orgDimensions share the same DB query. DAT-100
The new Elysia narrative route in apps/api needs fetchOrgKpis. DAT-100
GET returns a 2-3 sentence AI-generated executive summary of an organization's state over 7d/30d/90d, built from fetchOrgKpis + top insights. 1h Redis cache per (org, range). Rate limited 30/h per (org, user). Uses models.triage for the LLM call. DAT-100
Three TanStack Query hooks wrapping the new rpc procedures (orgSummary, orgDimensions) and the Elysia narrative endpoint. fetchInsightsOrgNarrative added to the shared insight-api lib. DAT-100
7d/30d/90d segmented selector persisted via atomWithStorage. Unifies the TimeRange type across the org hooks. DAT-100
2-3 sentence AI summary at the top of /insights. Wires useOrgNarrative, renders loading / error / content states, and shows a relative "Updated N minutes ago" footer. DAT-100
Visitors, Sessions, Bounce, Errors, LCP p75 wired to useOrgSummary. Reuses the existing StatCard component with sparklines and invertTrend for the three "lower is better" metrics. DAT-100
Some streaming states produce messages with an empty id, and
renderMessagePart composes inner keys as `${messageId}-${partIndex}`.
When two empty-id messages exist, their parts collide on "-0", "-1",
etc. and React throws the duplicate-key warning. Hoist a
`messageKey = message.id || \`msg-\${index}\`` once per message and use
it for both the outer <Message key> and the inner part-key base.
Bring back the /analyze, /sources, /funnel, /pages, /live, /anomalies, /compare, /report command palette. When the input starts with "/", a Popover anchored to the textarea width lists matching commands with icon + title + description. Selecting one fills the textarea with the command's full prompt template so the user can edit or send it. Pure cosmetic shortcut — no tool hints, no toolChoice wiring, every command still goes through the normal sendMessage path. Keyboard nav (arrow keys / enter / tab / escape) is driven from the textarea handler so focus never leaves the input. Clicking a row uses mousedown preventDefault to avoid stealing focus either.
Per-site tiles with favicon, name, health badge (healthy / attention / degraded), and a one-line reason from the most severe insight. deriveSiteHealth in lib/site-health.ts is a pure function operating on the existing insights feed. DAT-100
Pure extraction to prepare for the cockpit compose step. Filter bar, sort, dismiss/feedback state, and signals list now live in cockpit-signals.tsx. InsightsPageContent keeps the header and clear-all dialog wiring. No behavior change. DAT-100
Shows a title, icon, and up to N rows with value + signed change chip. Used by the cockpit for Countries / Pages / Referrers. DAT-100
Stacks narrative, KPI row, site health grid, signals feed, and dimension tiles. TimeRangeSelector lives in the PageHeader right slot. Empty-org state renders an add-website CTA in place of the cockpit when no sites exist. DAT-100
- aria-busy on cockpit scroll container - error states + retry in SiteHealthGrid and DimensionTile - aria-label on CockpitSignals section - focus-visible rings on SegmentedControl, filter buttons, retry links - phosphor icons use /dist/ssr subpath per project convention - skeleton count in SiteHealthGrid matches real site count DAT-100
The agent pins ANTHROPIC_CACHE_1H on user turns, which bills cache writes at $6/M, not the $3.75/M 5-minute rate the config assumed. Under-charged by ~32% on cached turns. Verified against 133 live Sonnet 4.6 gateway rows (gateway-inference-requests.csv): predicted spend with the new rate matches billed spend to within rounding ($5.8122 vs $5.81224). Plan allocations unchanged — free still gets 500, hobby 2500, pro 25k. Only the per-token credit conversion is corrected. - cache_write: 0.000_75 → 0.001_2 credits/token - agent-cost-probe.ts mirror updated to match
- fetchOrgKpis now queries analytics.error_spans and analytics.web_vitals_spans in parallel (removes stubs) - org-narrative falls back to a deterministic summary when the LLM returns empty and switches model from triage to analytics for reliability - SiteHealthGrid: 3-col grid, bigger tiles, shows total views + trend + live users + health pill with reason - DimensionTile: table-style rows with progress-bar backgrounds, country tile renders flag emojis from country codes DAT-100
Every creditCost multiplied by 1.20 so 1 sold credit represents $0.004167 of LLM compute instead of $0.005 at cost. Plan allocations (free 500, hobby 2500, pro 25k, scale 25k) unchanged — users silently burn credits ~20% faster, which moves hobby and pro toward breakeven on cached turns and makes the pro overage tiers ($0.0012, $0.001, $0.0008) no longer catastrophic on a $/$ basis. - input: 0.0006 → 0.000_72 - output: 0.003 → 0.003_6 - cache_read: 0.000_06 → 0.000_072 - cache_write: 0.001_2 → 0.001_44 Narrative comment block dropped — the numbers speak for themselves.
Perplexity sonar-pro calls from the web_search tool were completely invisible to the credit system — a single search costs us ~$0.0145 (verified from gateway-inference-requests.csv) but burned zero user credits. Spamming /search via the agent was effectively free. Add a new agent_web_search_calls metered feature charged at 5 credits per call (~$0.021 at post-markup $0.00417/credit, covers the observed cost with headroom for variance). billingCustomerId now flows through AgentContext → AppContext → experimental_context so the tool can autumn.track on success. Fire-and-forget with error logging so a tracking failure never blocks the agent's response. Title generation (gpt-oss-120b, ~$0.0002/call via cerebras) stays untracked — the code complexity isn't worth the pennies.
Swaps the custom parallel stack for the same useBatchDynamicQuery + StatCard + DataTable primitives the per-site overview tab uses. - insights-page-content.tsx: fetches summary_metrics, events_by_date, top_pages, top_referrers, country via useBatchDynamicQuery scoped to a focus site (picker in the header for multi-site orgs). Renders StatCards with sparklines and DataTables with existing createPageColumns / createReferrerColumns / createGeoColumns. - routers/insights.ts: remove orgSummary and orgDimensions RPC procedures (and their helpers). orgSocialReferrals stays. - index.ts: drop fetchOrgKpis / aggregateKpiSeries exports. - routes/insights.ts: narrative route no longer depends on fetchOrgKpis. It summarises the top 5 stored insights directly and falls back to a deterministic headline when the LLM returns empty. - lib/insight-api.ts: OrgNarrativeResponse drops deltas field. DAT-100
Reverts the SocialReferrals section and its backend plumbing. The design direction for /insights is moving toward the overview-tab card layout, where a bespoke collapsible social-platform section does not fit. - Delete SocialReferrals + SocialPlatformRow components - Delete useSocialReferrals hook - Delete social-referrals backend lib + tests - Drop orgSocialReferrals RPC procedure - Unmount from insights-page-content
…AT-100)
The cockpit children (CockpitNarrative, CockpitSignals, InsightCard)
were designed for a full-width page with no outer padding, each
section owning its own 'border-b px-4 sm:px-6'. After the recent
rewrite the page uses the overview-tab pattern (outer padded
container + card-shaped children), so the cockpit children were
double-padded and stranded borders floated in the middle of the
flex container.
- Outer container: 'flex flex-col gap-3 p-3 sm:gap-4 sm:p-4 lg:p-6'
-> 'space-y-3 p-4 sm:space-y-4' (matches overview-tab)
- CockpitNarrative: bordered section w/ gradient -> card with
'overflow-hidden rounded border bg-card' + TableToolbar-style
header (sparkle + title + 'Updated ago' meta on the right)
- CockpitSignals: wrap in card shell + add header row matching
SmartInsightsSection ('Signals' + visible/total count). Drop
sm:px-6 from filter bar and InsightsFetchStatusRow so content
aligns with the card insets.
- InsightCard: drop sm:px-6 so insight rows align with the new card
header padding. Add 'last:border-b-0' so the final row visually
merges with the card's outer border instead of doubling it.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: ASSERTIVE Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
izadoesdev
left a comment
There was a problem hiding this comment.
Automated First-Pass Review
What this PR does: Redesigns the Insights page from a flat signal list into a full AI cockpit with KPI stat cards, site health grid, dimension tables (pages/referrers/countries), and an LLM-generated narrative summary. Also threads billingCustomerId through the agent pipeline to meter web_search tool usage, applies a 20% pricing markup, restores the agent slash-command menu, and consolidates the insights data hooks into a shared useInsightsFeed.
PR description: ✅ Well-documented with summary sections and a test plan checklist.
Issues Found
🟡 Narrative DB query ignores time range — generateNarrativeCached fetches top insights by priority without filtering by creation date, so the "7d" narrative could include months-old signals. See inline comment.
🟡 Missing 401 status on unauthenticated /org-narrative — Returns 200 with an error body when userId is missing. See inline comment.
🟡 Unsafe type casts in cockpit page — Multiple as unknown as ColumnDef<...> double casts fully bypass TypeScript. See inline comment.
🔵 Removed cross-reference comment in cost probe — The // Matches creditSchema comment was a useful sync breadcrumb.
🔵 queries memo dependency may cause stale cockpit data — Only depends on granularity, but date range start/end also matter. See inline comment.
What looks good
- Auth + rate limiting on the new narrative endpoint is solid — org membership check, 30 req/hr per user+org, proper 403/429 responses.
- Graceful LLM fallback to deterministic narrative is a nice touch.
- Fire-and-forget billing tracking with proper
.catch()won't block the tool response. - The
InsightCardcompact variant and shareduseInsightsFeedhook are a clean DRY refactor. - No database schema/migration changes, no ClickHouse query changes.
- No hardcoded secrets or unsafe input handling detected.
Overall: Needs changes
The narrative date range bug is the main blocker — it'll show stale insights in the wrong time context. The missing 401 status is a quick fix. The rest are suggestions worth considering but non-blocking.
| ): Promise<{ narrative: string }> { | ||
| const topInsights = await db | ||
| .select({ | ||
| title: analyticsInsights.title, |
There was a problem hiding this comment.
🟡 Warning: Narrative query doesn't filter by time range
The DB query here fetches top insights by organizationId and priority, but never filters by date. When a user selects "30d" or "90d", this still returns the same insights regardless of their creation date. The range parameter only affects the LLM prompt text and cache key, so a "this week" narrative could surface a months-old insight.
Consider adding a date filter:
.where(
and(
eq(analyticsInsights.organizationId, organizationId),
gte(analyticsInsights.createdAt, dayjs().subtract(days, "day").toDate())
)
)| ) | ||
| .get( | ||
| "/org-narrative", | ||
| async ({ query, user, set }) => { |
There was a problem hiding this comment.
🟡 Warning: Missing HTTP status code for unauthenticated requests
When userId is falsy, this returns { success: false, error: "User ID required" } with a 200 status. Should set set.status = 401 before returning, otherwise clients will parse this as a successful response.
| @@ -58,12 +58,11 @@ if (!(websiteId && userId)) { | |||
| process.exit(1); | |||
| } | |||
|
|
|||
There was a problem hiding this comment.
🔵 Suggestion: Removing the cross-reference comment
This comment was a helpful breadcrumb for keeping the cost probe in sync with apps/dashboard/autumn.config.ts. With the 20% markup change, it'd be worth keeping some reference so the next person modifying either file knows to check the other.
| const summary = (getDataForQuery("cockpit-summary", "summary_metrics") ?? | ||
| [])[0] as SummaryRow | undefined; | ||
| const eventsByDate = (getDataForQuery("cockpit-summary", "events_by_date") ?? | ||
| []) as Record<string, unknown>[]; |
There was a problem hiding this comment.
🟡 Warning: Double as unknown as casts bypass TypeScript entirely
These triple-cast patterns (createPageColumns() as unknown as ColumnDef<...>) silence all type checking. If the column factories ever change their return shape, you won't get a compile error here. Consider typing the data rows to match what the columns actually expect, or at least adding a runtime assertion in dev mode.
| () => [ | ||
| { | ||
| id: "cockpit-summary", | ||
| parameters: ["summary_metrics", "events_by_date"], |
There was a problem hiding this comment.
🔵 Suggestion: queries memo deps may cause stale data
This useMemo only depends on dateRange.granularity, but the batch query hook also uses start_date and end_date from the dateRange object. If useBatchDynamicQuery doesn't independently react to the date range prop, changing from "7d" to "30d" (same granularity) won't trigger a new query definition. Double-check that useBatchDynamicQuery watches the full dateRange object for refetching.
Greptile SummaryThis PR redesigns the Insights page into an AI cockpit with KPI cards, site-health grid, dimension tiles, and a narrative powered by a new
Confidence Score: 4/5One P1 defect in the narrative endpoint should be fixed before merge — all other changes are clean. The DB query inside apps/api/src/routes/insights.ts — the
|
| Filename | Overview |
|---|---|
| apps/api/src/routes/insights.ts | Adds /org-narrative endpoint with rate limiting and caching; generateNarrativeCached ignores the range param in its DB query so all three time ranges return narratives built from the same data. |
| apps/dashboard/app/(main)/insights/_components/cockpit-narrative.tsx | New narrative card component; aria-label is hardcoded "Weekly summary" regardless of the selected range (30d/90d). |
| apps/dashboard/app/(main)/insights/_components/insights-page-content.tsx | Redesigned cockpit layout wiring KPI stat cards, dimension tables, CockpitNarrative, and CockpitSignals; looks clean with correct date-range handling for the stat cards. |
| apps/api/src/ai/tools/web-search.ts | Adds fire-and-forget Autumn billing tracking for web search calls; error is logged but never surfaced to caller, which is the correct approach. |
| apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-input.tsx | Restores slash-command menu with keyboard navigation (ArrowUp/Down, Tab, Escape, Enter); dismiss/reset logic and command selection look correct. |
| apps/dashboard/components/ai-elements/unicode-spinner.tsx | New animated unicode spinner with prefers-reduced-motion guard and font-stack override for Braille characters; well-implemented. |
| apps/dashboard/autumn.config.ts | Adds agent_web_search_calls metered feature and updates token credit costs to reflect 20% markup; matches the probe script constants. |
| apps/dashboard/lib/insight-api.ts | Adds fetchInsightsOrgNarrative with a 30s timeout and typed discriminated-union response; error handling is non-throwing which matches the hook's usage. |
| apps/dashboard/app/(main)/insights/_components/cockpit-signals.tsx | New signal grid with severity/site filtering, sort modes, and pagination; logic is straightforward and the Jotai-backed dismiss/feedback state is correctly scoped per org. |
Sequence Diagram
sequenceDiagram
participant Browser
participant CockpitNarrative
participant API as /v1/insights/org-narrative
participant Cache as Redis (cacheable)
participant DB as PostgreSQL
participant LLM
Browser->>CockpitNarrative: range change (7d / 30d / 90d)
CockpitNarrative->>API: GET ?organizationId=X&range=7d
API->>API: rate limit check
API->>Cache: lookup key insights-narrative:[orgId,range]
alt Cache hit
Cache-->>API: { narrative }
else Cache miss
API->>DB: SELECT top 5 insights WHERE orgId=X ORDER BY priority DESC
Note over DB,API: No date filter applied — same rows for all ranges
DB-->>API: topInsights[]
API->>LLM: prompt over the last 7d + topInsights
LLM-->>API: narrative text
API->>Cache: store with 1h TTL
end
API-->>Browser: { success, narrative, generatedAt }
Reviews (1): Last reviewed commit: "Update navigation-config.tsx" | Re-trigger Greptile
| 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); |
There was a problem hiding this comment.
range parameter ignored in DB query
generateNarrativeCached accepts range and includes it in the cache key (via cacheable), so three distinct cache entries are created for "7d", "30d", and "90d". However, the DB query below fetches the top 5 insights purely by priority with no date filter. Every range selection hits different Redis keys but reads the same Postgres rows, so "This month: X" and "This week: X" are generated from identical data — only the LLM prompt framing changes.
To fix, add a date-range where clause using the insight's period columns, for example:
const daysBack = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - daysBack);
const topInsights = await db
.select({ ... })
.from(analyticsInsights)
.innerJoin(websites, eq(analyticsInsights.websiteId, websites.id))
.where(
and(
eq(analyticsInsights.organizationId, organizationId),
gte(analyticsInsights.currentPeriodFrom, cutoff)
)
)
.orderBy(desc(analyticsInsights.priority))
.limit(NARRATIVE_INSIGHTS_LIMIT);| return ( | ||
| <section | ||
| aria-label="Weekly summary" | ||
| className="overflow-hidden rounded border bg-card" | ||
| > |
There was a problem hiding this comment.
Hardcoded
aria-label doesn't match selected range
The aria-label is always "Weekly summary" even when the user has selected 30d or 90d. Screen-reader users will hear an incorrect label.
| return ( | |
| <section | |
| aria-label="Weekly summary" | |
| className="overflow-hidden rounded border bg-card" | |
| > | |
| aria-label={range === "7d" ? "Weekly summary" : range === "30d" ? "Monthly summary" : "Quarterly summary"} |
| return fetchInsightsOrgNarrative(orgId, range); | ||
| }, | ||
| enabled: !!orgId, | ||
| staleTime: 60 * 60 * 1000, |
There was a problem hiding this comment.
Client-side
staleTime aligned with server TTL — note for after the DB fix
staleTime: 60 * 60 * 1000 (1 hour) matches the server's NARRATIVE_CACHE_TTL_SECS = 3600, which is reasonable. Once the DB query bug is fixed and different ranges return different data, this alignment will matter: the client won't refetch within the hour even if a new narrative would be available.
Summary
rpc/insightsand a corresponding Elysia route (/v1/insights/org-narrative), with KPI caching and cleaned-up bounce/session filters.Closes DAT-100.
Test plan