-
-
Notifications
You must be signed in to change notification settings - Fork 666
heatmaps #819
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
heatmaps #819
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,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); | ||
| } |
| 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, | ||
| }); | ||
| } | ||
| 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, | ||
| }); | ||
| } |
| 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"; |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add button type and ARIA state for the segmented control. 🛠️ Suggested tweak- <button
+ <button
+ type="button"
+ aria-pressed={viewportBreakpoint === option.value}
key={option.value}
onClick={() => onViewportChange(option.value)}
className={cn(📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||||||
| {option.icon} | ||||||||||||||||||||||||||||||||||||||||||
| {option.label} | ||||||||||||||||||||||||||||||||||||||||||
| </button> | ||||||||||||||||||||||||||||||||||||||||||
| ))} | ||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add button type and selection ARIA state for safety/accessibility. 🛠️ Suggested tweak- <button
+ <button
+ type="button"
+ aria-pressed={selectedPathname === page.pathname}
key={page.pathname}
onClick={() => onSelectPage(page.pathname)}
className={cn(📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||
| <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> | ||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: rybbit-io/rybbit
Length of output: 4886
🏁 Script executed:
Repository: rybbit-io/rybbit
Length of output: 50373
🏁 Script executed:
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
buildApiParamsuses internally. Currently, whenstore.timezoneis"system", the queryKey includes the literal"system"string while the request params contain the resolved timezone fromgetTimezone(). 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 oftimezone) or updatebuildApiParamsto accept timezone as a parameter and passstore.timezoneexplicitly, ensuring queryKey and request params stay in sync.🤖 Prompt for AI Agents