Skip to content

feat(insights): redesign insights as AI cockpit (DAT-100)#402

Merged
izadoesdev merged 49 commits intostagingfrom
izadoesdev/dat-100-redesign-insights-as-ai-cockpit
Apr 8, 2026
Merged

feat(insights): redesign insights as AI cockpit (DAT-100)#402
izadoesdev merged 49 commits intostagingfrom
izadoesdev/dat-100-redesign-insights-as-ai-cockpit

Conversation

@izadoesdev
Copy link
Copy Markdown
Member

Summary

  • Redesigns the Insights page as an org-wide AI cockpit: KPI row, site health grid, dimension tiles, and narrative — all driven by the existing query pipeline with a unified overview-tab layout.
  • Adds org-level KPI / dimension / narrative procedures in rpc/insights and a corresponding Elysia route (/v1/insights/org-narrative), with KPI caching and cleaned-up bounce/session filters.
  • Refreshes the agent surface: restores the slash-command menu as prompt shortcuts, adds animated unicode thinking spinners with rotating phrases, and polishes reasoning UI and thinking-effort toggle.
  • Misc: ungates insights/agent nav, applies 20% markup + web_search metering to billing, restores secondary button variant, and normalizes page/referrer dimension keys (including homepage).

Closes DAT-100.

Test plan

  • Insights page loads for an org with multiple sites — KPIs, health grid, dimensions, and narrative all render
  • Time range selector updates all cockpit sections
  • Agent slash-command menu opens and inserts prompts correctly
  • Thinking spinner + phrases animate during reasoning
  • Billing meters web_search tool usage against agent credits

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.
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 8, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dashboard Ready Ready Preview, Comment Apr 8, 2026 5:46pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
documentation Skipped Skipped Apr 8, 2026 5:46pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 8, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d2226dfc-a852-4b19-ade3-fd774ce6c9c8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch izadoesdev/dat-100-redesign-insights-as-ai-cockpit

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@izadoesdev izadoesdev merged commit ec6f9b3 into staging Apr 8, 2026
12 of 13 checks passed
Copy link
Copy Markdown
Member Author

@izadoesdev izadoesdev left a comment

Choose a reason for hiding this comment

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

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 rangegenerateNarrativeCached 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 InsightCard compact variant and shared useInsightsFeed hook 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,
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🟡 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 }) => {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🟡 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);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🔵 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>[];
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🟡 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"],
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🔵 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-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 8, 2026

Greptile Summary

This PR redesigns the Insights page into an AI cockpit with KPI cards, site-health grid, dimension tiles, and a narrative powered by a new /v1/insights/org-narrative endpoint. It also restores the agent slash-command menu, adds animated unicode thinking spinners, applies a 20 % billing markup to agent token rates, and meters web_search tool calls against Autumn.

  • generateNarrativeCached ignores range in its database query — the time-range parameter is included in the cache key and injected into the LLM prompt, but the underlying DB query fetches the top 5 insights purely by priority with no date filter. Every range selection returns a narrative generated from the same data, so users see "This month: X" and "This week: X" derived from identical rows.

Confidence Score: 4/5

One P1 defect in the narrative endpoint should be fixed before merge — all other changes are clean.

The DB query inside generateNarrativeCached ignores the range parameter, so every time-range selection produces a narrative from the same underlying insight rows. The UX impact is immediate: the time-range selector on the Insights cockpit gives users confidence they're seeing range-specific summaries when they're not. All other additions (billing, spinner, slash commands, stat cards, signals grid) are well-implemented and pose no blocking concerns.

apps/api/src/routes/insights.ts — the generateNarrativeCached function needs a date-range filter added to its DB query.

Vulnerabilities

No security concerns identified. The new /v1/insights/org-narrative endpoint correctly checks org membership before serving data, applies rate limiting, and does not expose raw DB rows. The web_search tool sanitizes content before returning it to the agent to prevent indirect prompt injection, and billing tracking is fire-and-forget with logged errors. The billingCustomerId flows through AgentContext and is never logged or returned to the client.

Important Files Changed

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 }
Loading

Reviews (1): Last reviewed commit: "Update navigation-config.tsx" | Re-trigger Greptile

Comment on lines +967 to +984
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 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);

Comment on lines +17 to +21
return (
<section
aria-label="Weekly summary"
className="overflow-hidden rounded border bg-card"
>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 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.

Suggested change
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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 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.

@izadoesdev izadoesdev mentioned this pull request Apr 8, 2026
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant