From eeb46affdab09ab5ce1c1f3932ffde25d3a0502c Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Mar 2026 13:37:36 +0100 Subject: [PATCH 01/61] docs: add region name fix to changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d8034..fb2eebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Fixed + +- **Region names now display correctly.** Some regions were showing as cryptic codes like "14" (Poland), "KKC" (Thailand), or "IDF" (France) instead of their actual names. The Locations panel now shows proper region names like "Masovian", "Khon Kaen", and "Île-de-France." + ## [0.14.0-alpha] - 2026-03-12 ### Improved From d728b49f6789752093a9bbc0fb29062c860cfe8c Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Mar 2026 14:31:44 +0100 Subject: [PATCH 02/61] feat: add report schedules API client module --- lib/api/report-schedules.ts | 73 +++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 lib/api/report-schedules.ts diff --git a/lib/api/report-schedules.ts b/lib/api/report-schedules.ts new file mode 100644 index 0000000..44b5e92 --- /dev/null +++ b/lib/api/report-schedules.ts @@ -0,0 +1,73 @@ +import apiRequest from './client' + +export interface ReportSchedule { + id: string + site_id: string + organization_id: string + channel: 'email' | 'slack' | 'discord' | 'webhook' + channel_config: EmailConfig | WebhookConfig + frequency: 'daily' | 'weekly' | 'monthly' + timezone: string + enabled: boolean + report_type: 'summary' | 'pages' | 'sources' | 'goals' + last_sent_at: string | null + last_error: string | null + created_at: string + updated_at: string +} + +export interface EmailConfig { + recipients: string[] +} + +export interface WebhookConfig { + url: string +} + +export interface CreateReportScheduleRequest { + channel: string + channel_config: EmailConfig | WebhookConfig + frequency: string + timezone?: string + report_type?: string +} + +export interface UpdateReportScheduleRequest { + channel?: string + channel_config?: EmailConfig | WebhookConfig + frequency?: string + timezone?: string + report_type?: string + enabled?: boolean +} + +export async function listReportSchedules(siteId: string): Promise { + const res = await apiRequest<{ schedules: ReportSchedule[] }>(`/sites/${siteId}/report-schedules`) + return res?.schedules ?? [] +} + +export async function createReportSchedule(siteId: string, data: CreateReportScheduleRequest): Promise { + return apiRequest(`/sites/${siteId}/report-schedules`, { + method: 'POST', + body: JSON.stringify(data), + }) +} + +export async function updateReportSchedule(siteId: string, scheduleId: string, data: UpdateReportScheduleRequest): Promise { + return apiRequest(`/sites/${siteId}/report-schedules/${scheduleId}`, { + method: 'PUT', + body: JSON.stringify(data), + }) +} + +export async function deleteReportSchedule(siteId: string, scheduleId: string): Promise { + await apiRequest(`/sites/${siteId}/report-schedules/${scheduleId}`, { + method: 'DELETE', + }) +} + +export async function testReportSchedule(siteId: string, scheduleId: string): Promise { + await apiRequest(`/sites/${siteId}/report-schedules/${scheduleId}/test`, { + method: 'POST', + }) +} From 31aff9555259edead68906e88aadb170a8af929e Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Mar 2026 14:31:47 +0100 Subject: [PATCH 03/61] feat: add Reports tab to site settings with schedule CRUD --- app/sites/[id]/settings/page.tsx | 411 ++++++++++++++++++++++++++++++- 1 file changed, 409 insertions(+), 2 deletions(-) diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index c17945d..e21ca00 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, useRef } from 'react' import { useParams, useRouter } from 'next/navigation' import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites' import { listGoals, createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals' +import { listReportSchedules, createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading } from '@/components/skeletons' @@ -25,6 +26,7 @@ import { AlertTriangleIcon, ZapIcon, } from '@ciphera-net/ui' +import { PaperPlaneTilt, Envelope, WebhooksLogo, SpinnerGap, Trash, PencilSimple, Play } from '@phosphor-icons/react' const TIMEZONES = [ 'UTC', @@ -54,7 +56,7 @@ export default function SiteSettingsPage() { const [site, setSite] = useState(null) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) - const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals'>('general') + const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals' | 'reports'>('general') const [formData, setFormData] = useState({ name: '', @@ -91,6 +93,22 @@ export default function SiteSettingsPage() { const [goalSaving, setGoalSaving] = useState(false) const initialFormRef = useRef('') + // Report schedules state + const [reportSchedules, setReportSchedules] = useState([]) + const [reportLoading, setReportLoading] = useState(false) + const [reportModalOpen, setReportModalOpen] = useState(false) + const [editingSchedule, setEditingSchedule] = useState(null) + const [reportSaving, setReportSaving] = useState(false) + const [reportTesting, setReportTesting] = useState(null) + const [reportForm, setReportForm] = useState({ + channel: 'email' as string, + recipients: '', + webhookUrl: '', + frequency: 'weekly' as string, + reportType: 'summary' as string, + timezone: '', + }) + useEffect(() => { loadSite() loadSubscription() @@ -102,6 +120,12 @@ export default function SiteSettingsPage() { } }, [activeTab, siteId]) + useEffect(() => { + if (activeTab === 'reports' && siteId) { + loadReportSchedules() + } + }, [activeTab, siteId]) + const loadSubscription = async () => { try { setSubscriptionLoadFailed(false) @@ -191,6 +215,150 @@ export default function SiteSettingsPage() { } } + const loadReportSchedules = async () => { + try { + setReportLoading(true) + const data = await listReportSchedules(siteId) + setReportSchedules(data) + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to load report schedules') + } finally { + setReportLoading(false) + } + } + + const resetReportForm = () => { + setReportForm({ + channel: 'email', + recipients: '', + webhookUrl: '', + frequency: 'weekly', + reportType: 'summary', + timezone: site?.timezone || '', + }) + } + + const openEditSchedule = (schedule: ReportSchedule) => { + setEditingSchedule(schedule) + const isEmail = schedule.channel === 'email' + setReportForm({ + channel: schedule.channel, + recipients: isEmail ? (schedule.channel_config as EmailConfig).recipients.join(', ') : '', + webhookUrl: !isEmail ? (schedule.channel_config as WebhookConfig).url : '', + frequency: schedule.frequency, + reportType: schedule.report_type, + timezone: schedule.timezone || site?.timezone || '', + }) + setReportModalOpen(true) + } + + const handleReportSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + let channelConfig: EmailConfig | WebhookConfig + if (reportForm.channel === 'email') { + const recipients = reportForm.recipients.split(',').map(r => r.trim()).filter(r => r.length > 0) + if (recipients.length === 0) { + toast.error('At least one recipient email is required') + return + } + channelConfig = { recipients } + } else { + if (!reportForm.webhookUrl.trim()) { + toast.error('Webhook URL is required') + return + } + channelConfig = { url: reportForm.webhookUrl.trim() } + } + + const payload: CreateReportScheduleRequest = { + channel: reportForm.channel, + channel_config: channelConfig, + frequency: reportForm.frequency, + timezone: reportForm.timezone || undefined, + report_type: reportForm.reportType, + } + + setReportSaving(true) + try { + if (editingSchedule) { + await updateReportSchedule(siteId, editingSchedule.id, payload) + toast.success('Report schedule updated') + } else { + await createReportSchedule(siteId, payload) + toast.success('Report schedule created') + } + setReportModalOpen(false) + loadReportSchedules() + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to save report schedule') + } finally { + setReportSaving(false) + } + } + + const handleReportDelete = async (schedule: ReportSchedule) => { + if (!confirm('Delete this report schedule?')) return + try { + await deleteReportSchedule(siteId, schedule.id) + toast.success('Report schedule deleted') + loadReportSchedules() + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to delete report schedule') + } + } + + const handleReportToggle = async (schedule: ReportSchedule) => { + try { + await updateReportSchedule(siteId, schedule.id, { enabled: !schedule.enabled }) + toast.success(schedule.enabled ? 'Report paused' : 'Report enabled') + loadReportSchedules() + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to update report schedule') + } + } + + const handleReportTest = async (schedule: ReportSchedule) => { + setReportTesting(schedule.id) + try { + await testReportSchedule(siteId, schedule.id) + toast.success('Test report sent successfully') + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to send test report') + } finally { + setReportTesting(null) + } + } + + const getChannelLabel = (channel: string) => { + switch (channel) { + case 'email': return 'Email' + case 'slack': return 'Slack' + case 'discord': return 'Discord' + case 'webhook': return 'Webhook' + default: return channel + } + } + + const getFrequencyLabel = (frequency: string) => { + switch (frequency) { + case 'daily': return 'Daily' + case 'weekly': return 'Weekly' + case 'monthly': return 'Monthly' + default: return frequency + } + } + + const getReportTypeLabel = (type: string) => { + switch (type) { + case 'summary': return 'Summary' + case 'pages': return 'Pages' + case 'sources': return 'Sources' + case 'goals': return 'Goals' + default: return type + } + } + const openAddGoal = () => { setEditingGoal(null) setGoalForm({ name: '', event_name: '' }) @@ -389,7 +557,7 @@ export default function SiteSettingsPage() {
@@ -476,6 +644,19 @@ export default function SiteSettingsPage() { Goals & Events + {/* Content Area */} @@ -1124,6 +1305,132 @@ export default function SiteSettingsPage() { )}
)} + + {activeTab === 'reports' && ( +
+
+
+

Scheduled Reports

+

Automatically deliver analytics reports via email or webhooks.

+
+ {canEdit && ( + + )} +
+ + {reportLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) : reportSchedules.length === 0 ? ( +
+ No scheduled reports yet. Add a report to automatically receive analytics summaries. +
+ ) : ( +
+ {reportSchedules.map((schedule) => ( +
+
+
+
+ {schedule.channel === 'email' ? ( + + ) : ( + + )} +
+
+
+ + {getChannelLabel(schedule.channel)} + + + {getFrequencyLabel(schedule.frequency)} + + + {getReportTypeLabel(schedule.report_type)} + +
+

+ {schedule.channel === 'email' + ? (schedule.channel_config as EmailConfig).recipients.join(', ') + : (schedule.channel_config as WebhookConfig).url} +

+
+ + Last sent: {schedule.last_sent_at + ? new Date(schedule.last_sent_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }) + : 'Never'} + +
+ {schedule.last_error && ( +

+ Error: {schedule.last_error} +

+ )} +
+
+ + {canEdit && ( +
+ + + + +
+ )} +
+
+ ))} +
+ )} +
+ )}
@@ -1177,6 +1484,106 @@ export default function SiteSettingsPage() { + setReportModalOpen(false)} + title={editingSchedule ? 'Edit report schedule' : 'Add report schedule'} + > +
+
+ +
+ {(['email', 'slack', 'discord', 'webhook'] as const).map((ch) => ( + + ))} +
+
+ + {reportForm.channel === 'email' ? ( +
+ + setReportForm({ ...reportForm, recipients: e.target.value })} + placeholder="email1@example.com, email2@example.com" + className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white" + required + /> +

Comma-separated email addresses.

+
+ ) : ( +
+ + setReportForm({ ...reportForm, webhookUrl: e.target.value })} + placeholder="https://hooks.example.com/..." + className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white" + required + /> +
+ )} + +
+ + setReportForm({ ...reportForm, reportType: v })} + options={[ + { value: 'summary', label: 'Summary' }, + { value: 'pages', label: 'Pages' }, + { value: 'sources', label: 'Sources' }, + { value: 'goals', label: 'Goals' }, + ]} + variant="input" + fullWidth + align="left" + /> +
+ +
+ + +
+
+
+ setShowVerificationModal(false)} From acf7b16dde4e485fea102a0773c399343e3fb748 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Mar 2026 14:44:06 +0100 Subject: [PATCH 04/61] docs: add scheduled reports to changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb2eebb..d0b479f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- **Scheduled Reports.** You can now get your analytics delivered automatically — set up daily, weekly, or monthly reports sent straight to your email, Slack, Discord, or any webhook. Each report includes your key stats (visitors, pageviews, bounce rate), top pages, and traffic sources, all in a clean branded format. Set them up in your site settings under the new "Reports" tab, and hit "Test" to preview before going live. You can create up to 10 schedules per site. + ### Fixed - **Region names now display correctly.** Some regions were showing as cryptic codes like "14" (Poland), "KKC" (Thailand), or "IDF" (France) instead of their actual names. The Locations panel now shows proper region names like "Masovian", "Khon Kaen", and "Île-de-France." From c6ec4671a49e0a8740b009e5ce417d622894698c Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Mar 2026 14:50:51 +0100 Subject: [PATCH 05/61] fix: match report_schedules JSON key from backend response --- lib/api/report-schedules.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/api/report-schedules.ts b/lib/api/report-schedules.ts index 44b5e92..a148de0 100644 --- a/lib/api/report-schedules.ts +++ b/lib/api/report-schedules.ts @@ -42,8 +42,8 @@ export interface UpdateReportScheduleRequest { } export async function listReportSchedules(siteId: string): Promise { - const res = await apiRequest<{ schedules: ReportSchedule[] }>(`/sites/${siteId}/report-schedules`) - return res?.schedules ?? [] + const res = await apiRequest<{ report_schedules: ReportSchedule[] }>(`/sites/${siteId}/report-schedules`) + return res?.report_schedules ?? [] } export async function createReportSchedule(siteId: string, data: CreateReportScheduleRequest): Promise { From 27a9836d5adcd2e309d2fb312b6c89e6f84e011a Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Mar 2026 15:17:46 +0100 Subject: [PATCH 06/61] feat: add time-of-day controls to scheduled reports UI Add send hour, day of week/month selectors to report schedule modal. Schedule cards now show descriptive delivery times like "Every Monday at 9:00 AM (UTC)". Timezone picker moved into modal. --- app/sites/[id]/settings/page.tsx | 100 ++++++++++++++++++++++++++++++- lib/api/report-schedules.ts | 7 +++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index e21ca00..0a32b42 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -107,6 +107,8 @@ export default function SiteSettingsPage() { frequency: 'weekly' as string, reportType: 'summary' as string, timezone: '', + sendHour: 9, + sendDay: 1, }) useEffect(() => { @@ -235,6 +237,8 @@ export default function SiteSettingsPage() { frequency: 'weekly', reportType: 'summary', timezone: site?.timezone || '', + sendHour: 9, + sendDay: 1, }) } @@ -248,6 +252,8 @@ export default function SiteSettingsPage() { frequency: schedule.frequency, reportType: schedule.report_type, timezone: schedule.timezone || site?.timezone || '', + sendHour: schedule.send_hour ?? 9, + sendDay: schedule.send_day ?? (schedule.frequency === 'monthly' ? 1 : 0), }) setReportModalOpen(true) } @@ -277,6 +283,8 @@ export default function SiteSettingsPage() { frequency: reportForm.frequency, timezone: reportForm.timezone || undefined, report_type: reportForm.reportType, + send_hour: reportForm.sendHour, + ...(reportForm.frequency !== 'daily' ? { send_day: reportForm.sendDay } : {}), } setReportSaving(true) @@ -349,6 +357,34 @@ export default function SiteSettingsPage() { } } + const WEEKDAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] + + const formatHour = (hour: number) => { + if (hour === 0) return '12:00 AM' + if (hour === 12) return '12:00 PM' + return hour < 12 ? `${hour}:00 AM` : `${hour - 12}:00 PM` + } + + const getScheduleDescription = (schedule: ReportSchedule) => { + const hour = formatHour(schedule.send_hour ?? 9) + const tz = schedule.timezone || 'UTC' + switch (schedule.frequency) { + case 'daily': + return `Every day at ${hour} (${tz})` + case 'weekly': { + const day = WEEKDAY_NAMES[schedule.send_day ?? 0] || 'Monday' + return `Every ${day} at ${hour} (${tz})` + } + case 'monthly': { + const d = schedule.send_day ?? 1 + const suffix = d === 1 ? 'st' : d === 2 ? 'nd' : d === 3 ? 'rd' : 'th' + return `${d}${suffix} of each month at ${hour} (${tz})` + } + default: + return schedule.frequency + } + } + const getReportTypeLabel = (type: string) => { switch (type) { case 'summary': return 'Summary' @@ -1367,7 +1403,10 @@ export default function SiteSettingsPage() { ? (schedule.channel_config as EmailConfig).recipients.join(', ') : (schedule.channel_config as WebhookConfig).url}

-
+

+ {getScheduleDescription(schedule)} +

+
Last sent: {schedule.last_sent_at ? new Date(schedule.last_sent_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }) @@ -1556,6 +1595,65 @@ export default function SiteSettingsPage() { />
+ {reportForm.frequency === 'weekly' && ( +
+ + setReportForm({ ...reportForm, sendDay: parseInt(v) })} + options={Array.from({ length: 28 }, (_, i) => { + const d = i + 1 + const suffix = d === 1 ? 'st' : d === 2 ? 'nd' : d === 3 ? 'rd' : 'th' + return { value: String(d), label: `${d}${suffix}` } + })} + variant="input" + fullWidth + align="left" + /> +
+ )} + +
+ + setReportForm({ ...reportForm, timezone: v })} + options={TIMEZONES.map((tz) => ({ value: tz, label: tz }))} + variant="input" + fullWidth + align="left" + /> +
+
{ + if (value === 'today') { + const today = formatDate(new Date()) + setDateRange({ start: today, end: today }) + setPeriod('today') + } else if (value === '7') { + setDateRange(getDateRange(7)) + setPeriod('7') + } else if (value === 'week') { + setDateRange(getThisWeekRange()) + setPeriod('week') + } else if (value === '30') { + setDateRange(getDateRange(30)) + setPeriod('30') + } else if (value === 'month') { + setDateRange(getThisMonthRange()) + setPeriod('month') + } else if (value === 'custom') { + setIsDatePickerOpen(true) + } + }} + options={[ + { value: 'today', label: 'Today' }, + { value: '7', label: 'Last 7 days' }, + { value: '30', label: 'Last 30 days' }, + { value: 'divider-1', label: '', divider: true }, + { value: 'week', label: 'This week' }, + { value: 'month', label: 'This month' }, + { value: 'divider-2', label: '', divider: true }, + { value: 'custom', label: 'Custom' }, + ]} + /> +
+ + {/* Summary cards */} + + + {/* Rage clicks + Dead clicks side by side */} +
+ + +
+ + {/* By page breakdown */} + + + {/* Scroll depth */} +
+ +
+ + setIsDatePickerOpen(false)} + onApply={(range) => { + setDateRange(range) + setPeriod('custom') + setIsDatePickerOpen(false) + }} + initialRange={dateRange} + /> +
+ ) +} From 9179e058f705008a7e2eea224f1bbfc71956471e Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Mar 2026 16:56:26 +0100 Subject: [PATCH 19/61] refactor: move scroll depth from dashboard to behavior tab --- app/sites/[id]/page.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index 639ef53..51a8a89 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -34,7 +34,6 @@ import TechSpecs from '@/components/dashboard/TechSpecs' const PerformanceStats = dynamic(() => import('@/components/dashboard/PerformanceStats')) const GoalStats = dynamic(() => import('@/components/dashboard/GoalStats')) -const ScrollDepth = dynamic(() => import('@/components/dashboard/ScrollDepth')) const Campaigns = dynamic(() => import('@/components/dashboard/Campaigns')) const PeakHours = dynamic(() => import('@/components/dashboard/PeakHours')) const EventProperties = dynamic(() => import('@/components/dashboard/EventProperties')) @@ -618,12 +617,11 @@ export default function SiteDashboardPage() { -
+
!/^scroll_\d+$/.test(g.event_name))} onSelectEvent={setSelectedEvent} /> -
{/* Event Properties Breakdown */} From 1f64bec46d0404ea6b7a7c442a9b6e4052c55599 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Mar 2026 17:02:52 +0100 Subject: [PATCH 20/61] fix: correct summary card label and skip MutationObserver on html/body --- .../behavior/FrustrationSummaryCards.tsx | 22 +++++++++---------- public/script.js | 5 +++-- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/components/behavior/FrustrationSummaryCards.tsx b/components/behavior/FrustrationSummaryCards.tsx index d1bd8a2..a271326 100644 --- a/components/behavior/FrustrationSummaryCards.tsx +++ b/components/behavior/FrustrationSummaryCards.tsx @@ -58,7 +58,7 @@ export default function FrustrationSummaryCards({ data, loading }: FrustrationSu const rageChange = pctChange(data.rage_clicks, data.prev_rage_clicks) const deadChange = pctChange(data.dead_clicks, data.prev_dead_clicks) const topPage = data.rage_top_page || data.dead_top_page - const topPageTotal = data.rage_clicks + data.dead_clicks + const totalSignals = data.rage_clicks + data.dead_clicks return (
@@ -94,22 +94,20 @@ export default function FrustrationSummaryCards({ data, loading }: FrustrationSu

- {/* Most Frustrated Page */} + {/* Total Frustration Signals */}

- Most Frustrated Page + Total Signals

+ + {totalSignals.toLocaleString()} + {topPage ? ( - <> -

- {topPage} -

-

- {topPageTotal.toLocaleString()} total signals -

- +

+ Top page: {topPage} +

) : ( -

+

No data in this period

)} diff --git a/public/script.js b/public/script.js index be787dd..4ead55b 100644 --- a/public/script.js +++ b/public/script.js @@ -631,8 +631,9 @@ }); var mutOpts = { childList: true, attributes: true, characterData: true, subtree: true }; mutationObs.observe(target, mutOpts); - if (target.parentElement) { - mutationObs.observe(target.parentElement, mutOpts); + var parent = target.parentElement; + if (parent && parent.tagName !== 'HTML' && parent.tagName !== 'BODY') { + mutationObs.observe(parent, { childList: true }); } } catch (ex) { mutationObs = null; From 585f37f444d75f4ef31536bdb564ee499ca4040b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Mar 2026 17:06:36 +0100 Subject: [PATCH 21/61] docs: add rage click and dead click detection to changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78b2f07..feae0b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- **Rage click detection.** Pulse now detects when visitors rapidly click the same element 3 or more times — a strong signal of UI frustration. Rage clicks are tracked automatically (no setup required) and surfaced in the new Behavior tab with the element, page, click count, and number of affected sessions. +- **Dead click detection.** Clicks on buttons, links, and other interactive elements that produce no visible result (no navigation, no DOM change, no network request) are now detected and reported. This helps you find broken buttons, disabled links, and unresponsive UI elements your visitors are struggling with. +- **Behavior tab.** A new tab in your site dashboard — alongside Dashboard, Uptime, and Funnels — dedicated to user behavior signals. Houses rage clicks, dead clicks, a by-page frustration breakdown, and scroll depth (moved from the main dashboard for a cleaner layout). +- **Frustration summary cards.** The Behavior tab opens with three at-a-glance cards: total rage clicks, total dead clicks, and total frustration signals with the most affected page — each with a percentage change compared to the previous period. - **Scheduled Reports.** You can now get your analytics delivered automatically — set up daily, weekly, or monthly reports sent straight to your email, Slack, Discord, or any webhook. Each report includes your key stats (visitors, pageviews, bounce rate), top pages, and traffic sources, all in a clean branded format. Set them up in your site settings under the new "Reports" tab, and hit "Test" to preview before going live. You can create up to 10 schedules per site. - **Time-of-day report scheduling.** Choose when your reports arrive — pick the hour, day of week (for weekly), or day of month (for monthly). Schedule cards show a human-readable description like "Every Monday at 9:00 AM (UTC)." +### Changed + +- **Scroll depth moved to Behavior tab.** The scroll depth radar chart has been relocated from the main dashboard to the new Behavior tab, where it fits more naturally alongside other user behavior metrics. + ### Fixed - **Region names now display correctly.** Some regions were showing as cryptic codes like "14" (Poland), "KKC" (Thailand), or "IDF" (France) instead of their actual names. The Locations panel now shows proper region names like "Masovian", "Khon Kaen", and "Île-de-France." From 2f01be1c67b657941e21671b3c7402d637b1f377 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Mar 2026 18:03:22 +0100 Subject: [PATCH 22/61] feat: polish behavior page UI with 8 improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add column headers to rage/dead click tables - Rich empty states with icons matching dashboard pattern - Add frustration trend comparison chart (current vs previous period) - Show "New" badge instead of misleading "+100%" when previous period is 0 - Click-to-copy on CSS selectors with toast feedback - Normalize min-height to 270px for consistent card sizing - Fix page title to include site domain (Behavior · domain | Pulse) - Add "last seen" column with relative timestamps --- app/sites/[id]/behavior/page.tsx | 9 +- .../behavior/FrustrationByPageTable.tsx | 13 +- .../behavior/FrustrationSummaryCards.tsx | 21 ++- components/behavior/FrustrationTable.tsx | 84 ++++++++-- components/behavior/FrustrationTrend.tsx | 158 ++++++++++++++++++ 5 files changed, 256 insertions(+), 29 deletions(-) create mode 100644 components/behavior/FrustrationTrend.tsx diff --git a/app/sites/[id]/behavior/page.tsx b/app/sites/[id]/behavior/page.tsx index f96b3cd..6de0d44 100644 --- a/app/sites/[id]/behavior/page.tsx +++ b/app/sites/[id]/behavior/page.tsx @@ -18,6 +18,7 @@ import { import FrustrationSummaryCards from '@/components/behavior/FrustrationSummaryCards' import FrustrationTable from '@/components/behavior/FrustrationTable' import FrustrationByPageTable from '@/components/behavior/FrustrationByPageTable' +import FrustrationTrend from '@/components/behavior/FrustrationTrend' import { useDashboard } from '@/lib/swr/dashboard' const ScrollDepth = dynamic(() => import('@/components/dashboard/ScrollDepth')) @@ -91,8 +92,9 @@ export default function BehaviorPage() { }, [fetchData]) useEffect(() => { - document.title = 'Behavior | Pulse' - }, []) + const domain = dashboard?.site?.domain + document.title = domain ? `Behavior · ${domain} | Pulse` : 'Behavior | Pulse' + }, [dashboard?.site?.domain]) const fetchAllRage = useCallback( () => getRageClicks(siteId, dateRange.start, dateRange.end, 100), @@ -181,12 +183,13 @@ export default function BehaviorPage() { {/* By page breakdown */} - {/* Scroll depth */} + {/* Scroll depth + Frustration trend */}
+
) : ( -
-

- No frustration signals detected in this period +

+
+ +
+

+ No frustration signals detected +

+

+ Page-level frustration data will appear here once rage clicks or dead clicks are detected on your site.

)} diff --git a/components/behavior/FrustrationSummaryCards.tsx b/components/behavior/FrustrationSummaryCards.tsx index a271326..304c974 100644 --- a/components/behavior/FrustrationSummaryCards.tsx +++ b/components/behavior/FrustrationSummaryCards.tsx @@ -7,16 +7,23 @@ interface FrustrationSummaryCardsProps { loading: boolean } -function pctChange(current: number, previous: number): number | null { +function pctChange(current: number, previous: number): { type: 'pct'; value: number } | { type: 'new' } | null { if (previous === 0 && current === 0) return null - if (previous === 0) return 100 - return Math.round(((current - previous) / previous) * 100) + if (previous === 0) return { type: 'new' } + return { type: 'pct', value: Math.round(((current - previous) / previous) * 100) } } -function ChangeIndicator({ change }: { change: number | null }) { +function ChangeIndicator({ change }: { change: ReturnType }) { if (change === null) return null - const isUp = change > 0 - const isDown = change < 0 + if (change.type === 'new') { + return ( + + New + + ) + } + const isUp = change.value > 0 + const isDown = change.value < 0 return ( - {isUp ? '+' : ''}{change}% + {isUp ? '+' : ''}{change.value}% ) } diff --git a/components/behavior/FrustrationTable.tsx b/components/behavior/FrustrationTable.tsx index 13e1276..cf1041c 100644 --- a/components/behavior/FrustrationTable.tsx +++ b/components/behavior/FrustrationTable.tsx @@ -2,8 +2,10 @@ import { useState, useEffect } from 'react' import { formatNumber, Modal } from '@ciphera-net/ui' -import { FrameCornersIcon } from '@phosphor-icons/react' +import { FrameCornersIcon, Copy, Check, CursorClick } from '@phosphor-icons/react' +import { toast } from '@ciphera-net/ui' import type { FrustrationElement } from '@/lib/api/stats' +import { formatRelativeTime } from '@/lib/utils/formatDate' import { ListSkeleton } from '@/components/skeletons' interface FrustrationTableProps { @@ -32,6 +34,37 @@ function SkeletonRows() { ) } +function SelectorCell({ selector }: { selector: string }) { + const [copied, setCopied] = useState(false) + + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation() + navigator.clipboard.writeText(selector) + setCopied(true) + toast.success('Selector copied') + setTimeout(() => setCopied(false), 2000) + } + + return ( + + ) +} + function Row({ item, showAvgClicks, @@ -42,14 +75,9 @@ function Row({ return (
+ - {item.selector} - - {item.page_path} @@ -64,6 +92,9 @@ function Row({ {item.sessions} {item.sessions === 1 ? 'session' : 'sessions'} + + {formatRelativeTime(item.last_seen)} + {formatNumber(item.count)} @@ -129,19 +160,40 @@ export default function FrustrationTable({ {description}

-
+
{loading ? ( ) : hasData ? ( -
- {items.map((item, i) => ( - - ))} +
+ {/* Column headers */} +
+
+ Selector + Page +
+
+ {showAvgClicks && Avg} + Sessions + Last Seen + Count +
+
+
+ {items.map((item, i) => ( + + ))} +
) : ( -
-

- No {title.toLowerCase()} detected in this period +

+
+ +
+

+ No {title.toLowerCase()} detected +

+

+ {description}. Data will appear here once frustration signals are detected on your site.

)} diff --git a/components/behavior/FrustrationTrend.tsx b/components/behavior/FrustrationTrend.tsx new file mode 100644 index 0000000..330b0a6 --- /dev/null +++ b/components/behavior/FrustrationTrend.tsx @@ -0,0 +1,158 @@ +'use client' + +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts' +import { TrendUp } from '@phosphor-icons/react' +import type { FrustrationSummary } from '@/lib/api/stats' + +interface FrustrationTrendProps { + summary: FrustrationSummary | null + loading: boolean +} + +function SkeletonCard() { + return ( +
+
+
+
+
+
+ {[120, 80, 140, 100].map((h, i) => ( +
+ ))} +
+
+ ) +} + +export default function FrustrationTrend({ summary, loading }: FrustrationTrendProps) { + if (loading || !summary) return + + const hasData = summary.rage_clicks > 0 || summary.dead_clicks > 0 || + summary.prev_rage_clicks > 0 || summary.prev_dead_clicks > 0 + + const chartData = [ + { + label: 'Rage', + current: summary.rage_clicks, + previous: summary.prev_rage_clicks, + }, + { + label: 'Dead', + current: summary.dead_clicks, + previous: summary.prev_dead_clicks, + }, + ] + + const totalCurrent = summary.rage_clicks + summary.dead_clicks + const totalPrevious = summary.prev_rage_clicks + summary.prev_dead_clicks + const totalChange = totalPrevious > 0 + ? Math.round(((totalCurrent - totalPrevious) / totalPrevious) * 100) + : null + + return ( +
+
+

+ Frustration Trend +

+
+

+ Current vs. previous period comparison +

+ + {hasData ? ( +
+ {/* Summary line */} +
+ + {totalCurrent.toLocaleString()} + + + total signals + + {totalChange !== null && ( + 0 + ? 'text-red-600 dark:text-red-400' + : totalChange < 0 + ? 'text-green-600 dark:text-green-400' + : 'text-neutral-500 dark:text-neutral-400' + }`}> + {totalChange > 0 ? '+' : ''}{totalChange}% + + )} + {totalChange === null && totalCurrent > 0 && ( + + New + + )} +
+ + {/* Chart */} +
+ + + + + [ + value.toLocaleString(), + name === 'current' ? 'Current' : 'Previous', + ]} + /> + + {chartData.map((_, i) => ( + + ))} + + + {chartData.map((_, i) => ( + + ))} + + + +
+ + {/* Legend */} +
+
+
+ Current period +
+
+
+ Previous period +
+
+
+ ) : ( +
+
+ +
+

+ No trend data yet +

+

+ Frustration trend data will appear here once rage clicks or dead clicks are detected across periods. +

+
+ )} +
+ ) +} From 0889079372429b66300457934eb09b58e369ec84 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Mar 2026 18:09:34 +0100 Subject: [PATCH 23/61] refactor: replace bar chart with pie chart for frustration trend --- components/behavior/FrustrationTrend.tsx | 213 +++++++++++------------ 1 file changed, 104 insertions(+), 109 deletions(-) diff --git a/components/behavior/FrustrationTrend.tsx b/components/behavior/FrustrationTrend.tsx index 330b0a6..afb9b01 100644 --- a/components/behavior/FrustrationTrend.tsx +++ b/components/behavior/FrustrationTrend.tsx @@ -1,7 +1,22 @@ 'use client' -import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts' import { TrendUp } from '@phosphor-icons/react' +import { Pie, PieChart } from 'recharts' + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@/components/charts' import type { FrustrationSummary } from '@/lib/api/stats' interface FrustrationTrendProps { @@ -16,131 +31,65 @@ function SkeletonCard() {
-
- {[120, 80, 140, 100].map((h, i) => ( -
- ))} +
+
) } +const chartConfig = { + count: { + label: 'Count', + }, + rage_clicks: { + label: 'Rage Clicks', + color: '#FD5E0F', + }, + dead_clicks: { + label: 'Dead Clicks', + color: '#F59E0B', + }, + prev_rage_clicks: { + label: 'Prev Rage Clicks', + color: '#78350F', + }, + prev_dead_clicks: { + label: 'Prev Dead Clicks', + color: '#92400E', + }, +} satisfies ChartConfig + export default function FrustrationTrend({ summary, loading }: FrustrationTrendProps) { if (loading || !summary) return const hasData = summary.rage_clicks > 0 || summary.dead_clicks > 0 || summary.prev_rage_clicks > 0 || summary.prev_dead_clicks > 0 - const chartData = [ - { - label: 'Rage', - current: summary.rage_clicks, - previous: summary.prev_rage_clicks, - }, - { - label: 'Dead', - current: summary.dead_clicks, - previous: summary.prev_dead_clicks, - }, - ] - const totalCurrent = summary.rage_clicks + summary.dead_clicks const totalPrevious = summary.prev_rage_clicks + summary.prev_dead_clicks const totalChange = totalPrevious > 0 ? Math.round(((totalCurrent - totalPrevious) / totalPrevious) * 100) : null - return ( -
-
-

- Frustration Trend -

-
-

- Current vs. previous period comparison -

+ const chartData = [ + { type: 'rage_clicks', count: summary.rage_clicks, fill: 'var(--color-rage_clicks)' }, + { type: 'dead_clicks', count: summary.dead_clicks, fill: 'var(--color-dead_clicks)' }, + { type: 'prev_rage_clicks', count: summary.prev_rage_clicks, fill: 'var(--color-prev_rage_clicks)' }, + { type: 'prev_dead_clicks', count: summary.prev_dead_clicks, fill: 'var(--color-prev_dead_clicks)' }, + ] - {hasData ? ( -
- {/* Summary line */} -
- - {totalCurrent.toLocaleString()} - - - total signals - - {totalChange !== null && ( - 0 - ? 'text-red-600 dark:text-red-400' - : totalChange < 0 - ? 'text-green-600 dark:text-green-400' - : 'text-neutral-500 dark:text-neutral-400' - }`}> - {totalChange > 0 ? '+' : ''}{totalChange}% - - )} - {totalChange === null && totalCurrent > 0 && ( - - New - - )} -
- - {/* Chart */} -
- - - - - [ - value.toLocaleString(), - name === 'current' ? 'Current' : 'Previous', - ]} - /> - - {chartData.map((_, i) => ( - - ))} - - - {chartData.map((_, i) => ( - - ))} - - - -
- - {/* Legend */} -
-
-
- Current period -
-
-
- Previous period -
-
+ if (!hasData) { + return ( +
+
+

+ Frustration Trend +

- ) : ( +

+ Current vs. previous period comparison +

@@ -152,7 +101,53 @@ export default function FrustrationTrend({ summary, loading }: FrustrationTrendP Frustration trend data will appear here once rage clicks or dead clicks are detected across periods.

- )} -
+
+ ) + } + + return ( + + + Frustration Trend + Current vs. previous period + + + + + } + /> + + + + + +
+ {totalChange !== null ? ( + <> + {totalChange > 0 ? 'Up' : totalChange < 0 ? 'Down' : 'No change'} by {Math.abs(totalChange)}% vs previous period + + ) : totalCurrent > 0 ? ( + <> + {totalCurrent.toLocaleString()} new signals this period + + ) : ( + 'No frustration signals detected' + )} +
+
+ {totalCurrent.toLocaleString()} total signals in current period +
+
+
) } From d4dc45e82b6d1353fd9167af7e89fa4087e6db5d Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Mar 2026 18:16:12 +0100 Subject: [PATCH 24/61] fix: align table headers with row data using CSS grid - Switch FrustrationTable from flex to grid columns so headers and row cells share the same column widths - Replace bar chart with pie chart for frustration trend - Remove Card wrapper borders, footer line, and total signals text - Change dead clicks color from yellow to darker orange --- components/behavior/FrustrationTable.tsx | 54 ++++++++++---------- components/behavior/FrustrationTrend.tsx | 63 +++++++++++------------- 2 files changed, 53 insertions(+), 64 deletions(-) diff --git a/components/behavior/FrustrationTable.tsx b/components/behavior/FrustrationTable.tsx index cf1041c..789ce5d 100644 --- a/components/behavior/FrustrationTable.tsx +++ b/components/behavior/FrustrationTable.tsx @@ -65,6 +65,9 @@ function SelectorCell({ selector }: { selector: string }) { ) } +const GRID_WITH_AVG = 'grid grid-cols-[1fr_60px_50px_64px_64px_40px] items-center gap-2 h-9 px-2 -mx-2' +const GRID_NO_AVG = 'grid grid-cols-[1fr_60px_64px_64px_40px] items-center gap-2 h-9 px-2 -mx-2' + function Row({ item, showAvgClicks, @@ -73,32 +76,30 @@ function Row({ showAvgClicks?: boolean }) { return ( -
-
+
+
{item.page_path}
-
- {showAvgClicks && item.avg_click_count != null && ( - - avg {item.avg_click_count.toFixed(1)} - - )} - - {item.sessions} {item.sessions === 1 ? 'session' : 'sessions'} + {showAvgClicks && ( + + {item.avg_click_count != null ? item.avg_click_count.toFixed(1) : '–'} - - {formatRelativeTime(item.last_seen)} - - - {formatNumber(item.count)} - -
+ )} + + {item.sessions} + + + {formatRelativeTime(item.last_seen)} + + + {formatNumber(item.count)} +
) } @@ -166,17 +167,12 @@ export default function FrustrationTable({ ) : hasData ? (
{/* Column headers */} -
-
- Selector - Page -
-
- {showAvgClicks && Avg} - Sessions - Last Seen - Count -
+
+ Selector / Page + {showAvgClicks && Avg} + Sessions + Last Seen + Count
{items.map((item, i) => ( diff --git a/components/behavior/FrustrationTrend.tsx b/components/behavior/FrustrationTrend.tsx index afb9b01..879b720 100644 --- a/components/behavior/FrustrationTrend.tsx +++ b/components/behavior/FrustrationTrend.tsx @@ -3,14 +3,6 @@ import { TrendUp } from '@phosphor-icons/react' import { Pie, PieChart } from 'recharts' -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@/components/ui/card' import { ChartContainer, ChartTooltip, @@ -48,7 +40,7 @@ const chartConfig = { }, dead_clicks: { label: 'Dead Clicks', - color: '#F59E0B', + color: '#E04E0A', }, prev_rage_clicks: { label: 'Prev Rage Clicks', @@ -106,12 +98,17 @@ export default function FrustrationTrend({ summary, loading }: FrustrationTrendP } return ( - - - Frustration Trend - Current vs. previous period - - +
+
+

+ Frustration Trend +

+
+

+ Rage and dead clicks split across current and previous period +

+ +
- - -
- {totalChange !== null ? ( - <> - {totalChange > 0 ? 'Up' : totalChange < 0 ? 'Down' : 'No change'} by {Math.abs(totalChange)}% vs previous period - - ) : totalCurrent > 0 ? ( - <> - {totalCurrent.toLocaleString()} new signals this period - - ) : ( - 'No frustration signals detected' - )} -
-
- {totalCurrent.toLocaleString()} total signals in current period -
-
- +
+ +
+ {totalChange !== null ? ( + <> + {totalChange > 0 ? 'Up' : totalChange < 0 ? 'Down' : 'No change'} by {Math.abs(totalChange)}% vs previous period + + ) : totalCurrent > 0 ? ( + <> + {totalCurrent.toLocaleString()} new signals this period + + ) : ( + 'No frustration signals detected' + )} +
+
) } From bf7fe87120d8002dede6291eb9f6272da4d0be8f Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Mar 2026 18:21:19 +0100 Subject: [PATCH 25/61] fix: use direct hex colors for pie chart tooltip and distinct color palette --- components/behavior/FrustrationTrend.tsx | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/components/behavior/FrustrationTrend.tsx b/components/behavior/FrustrationTrend.tsx index 879b720..e66cd72 100644 --- a/components/behavior/FrustrationTrend.tsx +++ b/components/behavior/FrustrationTrend.tsx @@ -30,25 +30,32 @@ function SkeletonCard() { ) } +const COLORS = { + rage_clicks: '#FD5E0F', + dead_clicks: '#3B82F6', + prev_rage_clicks: '#9A3412', + prev_dead_clicks: '#1E40AF', +} as const + const chartConfig = { count: { label: 'Count', }, rage_clicks: { label: 'Rage Clicks', - color: '#FD5E0F', + color: COLORS.rage_clicks, }, dead_clicks: { label: 'Dead Clicks', - color: '#E04E0A', + color: COLORS.dead_clicks, }, prev_rage_clicks: { label: 'Prev Rage Clicks', - color: '#78350F', + color: COLORS.prev_rage_clicks, }, prev_dead_clicks: { label: 'Prev Dead Clicks', - color: '#92400E', + color: COLORS.prev_dead_clicks, }, } satisfies ChartConfig @@ -65,10 +72,10 @@ export default function FrustrationTrend({ summary, loading }: FrustrationTrendP : null const chartData = [ - { type: 'rage_clicks', count: summary.rage_clicks, fill: 'var(--color-rage_clicks)' }, - { type: 'dead_clicks', count: summary.dead_clicks, fill: 'var(--color-dead_clicks)' }, - { type: 'prev_rage_clicks', count: summary.prev_rage_clicks, fill: 'var(--color-prev_rage_clicks)' }, - { type: 'prev_dead_clicks', count: summary.prev_dead_clicks, fill: 'var(--color-prev_dead_clicks)' }, + { type: 'rage_clicks', count: summary.rage_clicks, fill: COLORS.rage_clicks }, + { type: 'dead_clicks', count: summary.dead_clicks, fill: COLORS.dead_clicks }, + { type: 'prev_rage_clicks', count: summary.prev_rage_clicks, fill: COLORS.prev_rage_clicks }, + { type: 'prev_dead_clicks', count: summary.prev_dead_clicks, fill: COLORS.prev_dead_clicks }, ] if (!hasData) { From 13f6f53868a8b5dbb51129886ac5db7af244901b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Mar 2026 18:25:15 +0100 Subject: [PATCH 26/61] fix: tooltip indicator dot not showing slice color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bg-[--color-bg] doesn't resolve in Tailwind v4 — needs var() wrapper. Changed to bg-[var(--color-bg)] and border-[var(--color-border)]. --- components/charts/chart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/charts/chart.tsx b/components/charts/chart.tsx index a4249f5..2424041 100644 --- a/components/charts/chart.tsx +++ b/components/charts/chart.tsx @@ -174,7 +174,7 @@ const ChartTooltipContent = React.forwardRef< !hideIndicator && (
Date: Thu, 12 Mar 2026 18:27:20 +0100 Subject: [PATCH 27/61] refactor: match frustration tables to dashboard pattern - Remove column headers for cleaner look - Show secondary info (avg, sessions, last seen) on hover - Add orange percentage badge that slides in on hover - Add empty row padding for consistent card height --- app/sites/[id]/behavior/page.tsx | 2 + components/behavior/FrustrationTable.tsx | 87 ++++++++++++------------ 2 files changed, 46 insertions(+), 43 deletions(-) diff --git a/app/sites/[id]/behavior/page.tsx b/app/sites/[id]/behavior/page.tsx index 6de0d44..8c93ffa 100644 --- a/app/sites/[id]/behavior/page.tsx +++ b/app/sites/[id]/behavior/page.tsx @@ -166,6 +166,7 @@ export default function BehaviorPage() { description="Elements users clicked repeatedly in frustration" items={rageClicks.items} total={rageClicks.total} + totalSignals={summary?.rage_clicks ?? 0} showAvgClicks loading={loading} fetchAll={fetchAllRage} @@ -175,6 +176,7 @@ export default function BehaviorPage() { description="Elements users clicked that produced no response" items={deadClicks.items} total={deadClicks.total} + totalSignals={summary?.dead_clicks ?? 0} loading={loading} fetchAll={fetchAllDead} /> diff --git a/components/behavior/FrustrationTable.tsx b/components/behavior/FrustrationTable.tsx index 789ce5d..5fedd0f 100644 --- a/components/behavior/FrustrationTable.tsx +++ b/components/behavior/FrustrationTable.tsx @@ -8,11 +8,14 @@ import type { FrustrationElement } from '@/lib/api/stats' import { formatRelativeTime } from '@/lib/utils/formatDate' import { ListSkeleton } from '@/components/skeletons' +const DISPLAY_LIMIT = 7 + interface FrustrationTableProps { title: string description: string items: FrustrationElement[] total: number + totalSignals: number showAvgClicks?: boolean loading: boolean fetchAll?: () => Promise<{ items: FrustrationElement[]; total: number }> @@ -21,7 +24,7 @@ interface FrustrationTableProps { function SkeletonRows() { return (
- {Array.from({ length: 5 }).map((_, i) => ( + {Array.from({ length: DISPLAY_LIMIT }).map((_, i) => (
@@ -51,7 +54,7 @@ function SelectorCell({ selector }: { selector: string }) { className="flex items-center gap-1 min-w-0 group/copy cursor-pointer" title={selector} > - + {selector} @@ -65,41 +68,44 @@ function SelectorCell({ selector }: { selector: string }) { ) } -const GRID_WITH_AVG = 'grid grid-cols-[1fr_60px_50px_64px_64px_40px] items-center gap-2 h-9 px-2 -mx-2' -const GRID_NO_AVG = 'grid grid-cols-[1fr_60px_64px_64px_40px] items-center gap-2 h-9 px-2 -mx-2' - function Row({ item, showAvgClicks, + totalSignals, }: { item: FrustrationElement showAvgClicks?: boolean + totalSignals: number }) { + const pct = totalSignals > 0 ? `${Math.round((item.count / totalSignals) * 100)}%` : '' + return ( -
-
- - - {item.page_path} +
+
+
+ + + {item.page_path} + +
+
+
+ {/* Secondary info: visible on hover */} + + {showAvgClicks && item.avg_click_count != null ? `avg ${item.avg_click_count.toFixed(1)} · ` : ''} + {item.sessions} {item.sessions === 1 ? 'sess' : 'sess'} · {formatRelativeTime(item.last_seen)} + + {/* Percentage badge: slides in on hover */} + + {pct} + + + {formatNumber(item.count)}
- {showAvgClicks && ( - - {item.avg_click_count != null ? item.avg_click_count.toFixed(1) : '–'} - - )} - - {item.sessions} - - - {formatRelativeTime(item.last_seen)} - - - {formatNumber(item.count)} -
) } @@ -109,6 +115,7 @@ export default function FrustrationTable({ description, items, total, + totalSignals, showAvgClicks, loading, fetchAll, @@ -118,6 +125,7 @@ export default function FrustrationTable({ const [isLoadingFull, setIsLoadingFull] = useState(false) const hasData = items.length > 0 const showViewAll = hasData && total > items.length + const emptySlots = Math.max(0, DISPLAY_LIMIT - items.length) useEffect(() => { if (isModalOpen && fetchAll) { @@ -165,21 +173,14 @@ export default function FrustrationTable({ {loading ? ( ) : hasData ? ( -
- {/* Column headers */} -
- Selector / Page - {showAvgClicks && Avg} - Sessions - Last Seen - Count -
-
- {items.map((item, i) => ( - - ))} -
-
+ <> + {items.map((item, i) => ( + + ))} + {Array.from({ length: emptySlots }).map((_, i) => ( +