Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions client/src/api/analytics/endpoints/heatmap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { authedFetch } from "../../utils";
import { CommonApiParams, toQueryParams } from "./types";

// Heatmap data point type
export interface HeatmapDataPoint {
x: number; // 0-100 percentage
y: number; // 0-100 percentage
value: number; // click count
}

// Click heatmap result type
export interface ClickHeatmapResult {
points: HeatmapDataPoint[];
totalClicks: number;
uniqueSessions: number;
}

// Click heatmap response type
export interface ClickHeatmapResponse {
data: ClickHeatmapResult;
pathname: string;
}

// Heatmap page type
export interface HeatmapPage {
pathname: string;
clickCount: number;
sessionCount: number;
}

// Heatmap pages response type
export interface HeatmapPagesResponse {
data: HeatmapPage[];
}

// Viewport breakpoint type
export type ViewportBreakpoint = "mobile" | "tablet" | "desktop" | "all";

// Click heatmap params
export interface ClickHeatmapParams extends CommonApiParams {
pathname: string;
viewportBreakpoint?: ViewportBreakpoint;
gridResolution?: number;
}

// Heatmap pages params
export interface HeatmapPagesParams extends CommonApiParams {
limit?: number;
}

/**
* Fetch click heatmap data for a specific page
* GET /api/sites/:siteId/heatmap/clicks
*/
export async function fetchClickHeatmap(
site: string | number,
params: ClickHeatmapParams
): Promise<ClickHeatmapResponse> {
const queryParams = {
...toQueryParams(params),
pathname: params.pathname,
viewportBreakpoint: params.viewportBreakpoint,
gridResolution: params.gridResolution,
};

return authedFetch<ClickHeatmapResponse>(`/sites/${site}/heatmap/clicks`, queryParams);
}

/**
* Fetch list of pages with click data for heatmaps
* GET /api/sites/:siteId/heatmap/pages
*/
export async function fetchHeatmapPages(
site: string | number,
params: HeatmapPagesParams
): Promise<HeatmapPagesResponse> {
const queryParams = {
...toQueryParams(params),
limit: params.limit,
};

return authedFetch<HeatmapPagesResponse>(`/sites/${site}/heatmap/pages`, queryParams);
}
13 changes: 13 additions & 0 deletions client/src/api/analytics/endpoints/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,16 @@ export type {
// Export endpoints
export { exportPdfReport } from "./export";
export type { ExportPdfParams } from "./export";

// Heatmap endpoints
export { fetchClickHeatmap, fetchHeatmapPages } from "./heatmap";
export type {
HeatmapDataPoint,
ClickHeatmapResult,
ClickHeatmapResponse,
HeatmapPage,
HeatmapPagesResponse,
ViewportBreakpoint,
ClickHeatmapParams,
HeatmapPagesParams,
} from "./heatmap";
35 changes: 35 additions & 0 deletions client/src/api/analytics/hooks/heatmap/useGetClickHeatmap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useQuery } from "@tanstack/react-query";
import { useStore } from "../../../../lib/store";
import { buildApiParams } from "../../../utils";
import { fetchClickHeatmap, ViewportBreakpoint } from "../../endpoints/heatmap";

interface UseGetClickHeatmapOptions {
pathname: string;
viewportBreakpoint?: ViewportBreakpoint;
gridResolution?: number;
enabled?: boolean;
}

export function useGetClickHeatmap({
pathname,
viewportBreakpoint = "all",
gridResolution = 100,
enabled = true,
}: UseGetClickHeatmapOptions) {
const { time, site, filters, timezone } = useStore();
const params = buildApiParams(time, { filters });

return useQuery({
queryKey: ["click-heatmap", site, pathname, viewportBreakpoint, gridResolution, time, filters, timezone],
queryFn: () =>
fetchClickHeatmap(site, {
...params,
pathname,
viewportBreakpoint,
gridResolution,
}),
staleTime: 1000 * 60 * 5, // 5 minutes
refetchOnWindowFocus: false,
enabled: !!site && !!pathname && enabled,
Comment on lines +19 to +33
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.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find buildApiParams implementation
rg -n --type=ts "buildApiParams" -B2 -A10 | head -50

Repository: rybbit-io/rybbit

Length of output: 4886


🏁 Script executed:

#!/bin/bash
# Find getTimezone implementation
rg -n --type=ts "getTimezone" -B2 -A8

Repository: rybbit-io/rybbit

Length of output: 50373


🏁 Script executed:

#!/bin/bash
# Look for timezone handling in the analytics/hooks directory
fd . client/src/api/analytics -type f -name "*.ts" -o -name "*.tsx" | head -20

Repository: rybbit-io/rybbit

Length of output: 230


Pass the resolved timezone to queryKey for cache consistency.

The queryKey should use the same resolved timezone value that buildApiParams uses internally. Currently, when store.timezone is "system", the queryKey includes the literal "system" string while the request params contain the resolved timezone from getTimezone(). If the resolved timezone changes at runtime, React Query will serve stale cached data with an outdated timezone.

Either include the resolved timezone in the queryKey (getTimezone() instead of timezone) or update buildApiParams to accept timezone as a parameter and pass store.timezone explicitly, ensuring queryKey and request params stay in sync.

🤖 Prompt for AI Agents
In `@client/src/api/analytics/hooks/heatmap/useGetClickHeatmap.ts` around lines 19
- 33, The queryKey in useGetClickHeatmap is using the raw store timezone
variable while buildApiParams resolves it (via getTimezone()), causing cache
mismatches; update the hook so the queryKey uses the resolved timezone value
(call getTimezone() or otherwise obtain the same resolved timezone used by
buildApiParams) instead of the literal timezone from useStore(), or
alternatively change buildApiParams to accept an explicit timezone and pass
store.timezone into it so both the queryKey and the request params use the same
timezone resolution; locate symbols useGetClickHeatmap, useStore,
buildApiParams, getTimezone(), and the queryKey to make the change.

});
}
26 changes: 26 additions & 0 deletions client/src/api/analytics/hooks/heatmap/useGetHeatmapPages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { useStore } from "../../../../lib/store";
import { buildApiParams } from "../../../utils";
import { fetchHeatmapPages } from "../../endpoints/heatmap";

interface UseGetHeatmapPagesOptions {
limit?: number;
enabled?: boolean;
}

export function useGetHeatmapPages({ limit = 100, enabled = true }: UseGetHeatmapPagesOptions = {}) {
const { time, site, filters, timezone } = useStore();
const params = buildApiParams(time, { filters });

return useQuery({
queryKey: ["heatmap-pages", site, limit, time, filters, timezone],
queryFn: () =>
fetchHeatmapPages(site, {
...params,
limit,
}),
staleTime: 1000 * 60 * 5, // 5 minutes
refetchOnWindowFocus: false,
enabled: !!site && enabled,
});
}
2 changes: 2 additions & 0 deletions client/src/app/[site]/[privateKey]/heatmaps/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Re-export the heatmaps page for private link routes
export { default } from "../../heatmaps/page";
11 changes: 11 additions & 0 deletions client/src/app/[site]/components/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ChartColumnDecreasing,
Code,
File,
Flame,
Funnel,
Gauge,
Globe2,
Expand Down Expand Up @@ -124,6 +125,16 @@ function SidebarContent() {
/>
)}
</div>
<div className="hidden md:block">
{!subscription?.planName?.startsWith("appsumo") && !isSubscriptionLoading && (
<SidebarComponents.Item
label="Heatmaps"
active={isActiveTab("heatmaps")}
href={getTabPath("heatmaps")}
icon={<Flame className="w-4 h-4" />}
/>
)}
</div>
<SidebarComponents.Item
label="Funnels"
active={isActiveTab("funnels")}
Expand Down
42 changes: 42 additions & 0 deletions client/src/app/[site]/heatmaps/components/HeatmapControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"use client";

import { Monitor, Smartphone, Tablet } from "lucide-react";
import { ViewportBreakpoint } from "../../../../api/analytics/endpoints/heatmap";
import { cn } from "../../../../lib/utils";

interface HeatmapControlsProps {
viewportBreakpoint: ViewportBreakpoint;
onViewportChange: (breakpoint: ViewportBreakpoint) => void;
}

const VIEWPORT_OPTIONS: { value: ViewportBreakpoint; label: string; icon: React.ReactNode }[] = [
{ value: "all", label: "All", icon: null },
{ value: "desktop", label: "Desktop", icon: <Monitor className="w-4 h-4" /> },
{ value: "tablet", label: "Tablet", icon: <Tablet className="w-4 h-4" /> },
{ value: "mobile", label: "Mobile", icon: <Smartphone className="w-4 h-4" /> },
];

export function HeatmapControls({ viewportBreakpoint, onViewportChange }: HeatmapControlsProps) {
return (
<div className="flex items-center gap-2">
<span className="text-sm text-neutral-500 dark:text-neutral-400">Viewport:</span>
<div className="flex items-center bg-neutral-100 dark:bg-neutral-800 rounded-lg p-0.5">
{VIEWPORT_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => onViewportChange(option.value)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors",
viewportBreakpoint === option.value
? "bg-white dark:bg-neutral-700 text-neutral-900 dark:text-neutral-100 shadow-sm"
: "text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-200"
)}
Comment on lines +25 to +33
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.

⚠️ Potential issue | 🟡 Minor

Add button type and ARIA state for the segmented control.
Improves a11y and avoids accidental form submission.

🛠️ Suggested tweak
-          <button
+          <button
+            type="button"
+            aria-pressed={viewportBreakpoint === option.value}
             key={option.value}
             onClick={() => onViewportChange(option.value)}
             className={cn(
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
key={option.value}
onClick={() => onViewportChange(option.value)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors",
viewportBreakpoint === option.value
? "bg-white dark:bg-neutral-700 text-neutral-900 dark:text-neutral-100 shadow-sm"
: "text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-200"
)}
<button
type="button"
aria-pressed={viewportBreakpoint === option.value}
key={option.value}
onClick={() => onViewportChange(option.value)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors",
viewportBreakpoint === option.value
? "bg-white dark:bg-neutral-700 text-neutral-900 dark:text-neutral-100 shadow-sm"
: "text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-200"
)}
🤖 Prompt for AI Agents
In `@client/src/app/`[site]/heatmaps/components/HeatmapControls.tsx around lines
25 - 33, The segmented control buttons in HeatmapControls currently lack an
explicit button type and ARIA state; update the button element rendered in
HeatmapControls (the button with key={option.value} and onClick={() =>
onViewportChange(option.value)}) to include type="button" to prevent accidental
form submissions and add aria-pressed={viewportBreakpoint === option.value} (or
aria-pressed={String(viewportBreakpoint === option.value)}) so screen readers
can convey the toggle state; keep the existing className/cn logic and
onViewportChange handler unchanged.

>
{option.icon}
{option.label}
</button>
))}
</div>
</div>
);
}
63 changes: 63 additions & 0 deletions client/src/app/[site]/heatmaps/components/HeatmapPageList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"use client";

import { FileText, Loader2 } from "lucide-react";
import { HeatmapPage } from "../../../../api/analytics/endpoints/heatmap";
import { cn } from "../../../../lib/utils";

interface HeatmapPageListProps {
pages: HeatmapPage[];
selectedPathname: string | null;
onSelectPage: (pathname: string) => void;
isLoading: boolean;
}

export function HeatmapPageList({ pages, selectedPathname, onSelectPage, isLoading }: HeatmapPageListProps) {
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-6 h-6 animate-spin text-neutral-400" />
</div>
);
}

if (pages.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-center px-4">
<FileText className="w-8 h-8 text-neutral-400 mb-2" />
<p className="text-sm text-neutral-500 dark:text-neutral-400">No pages with click data found</p>
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
Click data will appear once session replay captures user interactions
</p>
</div>
);
}

return (
<div className="flex flex-col gap-1 overflow-y-auto h-full">
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 px-2 py-1 uppercase tracking-wide">
Pages ({pages.length})
</div>
{pages.map((page) => (
<button
key={page.pathname}
onClick={() => onSelectPage(page.pathname)}
className={cn(
"flex items-center justify-between px-3 py-2 rounded-lg text-left transition-colors",
"hover:bg-neutral-100 dark:hover:bg-neutral-800",
selectedPathname === page.pathname && "bg-neutral-100 dark:bg-neutral-800 ring-1 ring-neutral-200 dark:ring-neutral-700"
)}
Comment on lines +41 to +48
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.

⚠️ Potential issue | 🟡 Minor

Add button type and selection ARIA state for safety/accessibility.
Prevents accidental form submit and improves screen-reader state.

🛠️ Suggested tweak
-        <button
+        <button
+          type="button"
+          aria-pressed={selectedPathname === page.pathname}
           key={page.pathname}
           onClick={() => onSelectPage(page.pathname)}
           className={cn(
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
key={page.pathname}
onClick={() => onSelectPage(page.pathname)}
className={cn(
"flex items-center justify-between px-3 py-2 rounded-lg text-left transition-colors",
"hover:bg-neutral-100 dark:hover:bg-neutral-800",
selectedPathname === page.pathname && "bg-neutral-100 dark:bg-neutral-800 ring-1 ring-neutral-200 dark:ring-neutral-700"
)}
<button
type="button"
aria-pressed={selectedPathname === page.pathname}
key={page.pathname}
onClick={() => onSelectPage(page.pathname)}
className={cn(
"flex items-center justify-between px-3 py-2 rounded-lg text-left transition-colors",
"hover:bg-neutral-100 dark:hover:bg-neutral-800",
selectedPathname === page.pathname && "bg-neutral-100 dark:bg-neutral-800 ring-1 ring-neutral-200 dark:ring-neutral-700"
)}
🤖 Prompt for AI Agents
In `@client/src/app/`[site]/heatmaps/components/HeatmapPageList.tsx around lines
41 - 48, The button in HeatmapPageList.tsx should explicitly set type="button"
to avoid accidental form submissions and include an ARIA selection state so
screen readers know which page is selected; update the button rendered in the
map (the element using onSelectPage, selectedPathname and page.pathname) to add
type="button" and an appropriate ARIA attribute (e.g., aria-pressed or
aria-selected) set to the boolean expression selectedPathname === page.pathname.

>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100 truncate">{page.pathname}</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{page.sessionCount.toLocaleString()} sessions
</div>
</div>
<div className="ml-2 px-2 py-0.5 bg-neutral-200 dark:bg-neutral-700 rounded text-xs font-medium text-neutral-700 dark:text-neutral-300">
{page.clickCount.toLocaleString()}
</div>
</button>
))}
</div>
);
}
Loading