From 2c82c1a52abc76819e46b67b5021632d0385c089 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 6 Mar 2026 23:27:54 +0100 Subject: [PATCH] fix: load full filter suggestions (up to 100) and fix Direct referrer duplicate Filter dropdowns previously only showed ~10 values from cached dashboard data. Now lazy-loads up to 100 values per dimension when the dropdown opens. Also removes duplicate "Direct" entry from referrer suggestions. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 17 ++-- app/sites/[id]/page.tsx | 104 +++++++++++++++++---- components/dashboard/AddFilterDropdown.tsx | 53 ++++++++--- lib/filters.ts | 4 +- 4 files changed, 138 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b98cb8..2f38e55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,15 +10,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added -- **Hover percentages on dashboard panels.** When you hover over any item in the Content, Locations, Technology, or Top Referrers panels, a percentage now smoothly slides in next to the count — showing you at a glance how much of total traffic that item represents. -- **Click any item to filter your dashboard.** Clicking a referrer, browser, country, city, page, OS, or device in any dashboard panel now instantly filters your entire dashboard to show only that traffic. No need to open the filter modal — just click the item you're curious about. -- **Redesigned filter button.** The old dashed "Add filter" dropdown has been replaced with a clean modal. Pick a dimension from a visual grid, choose an operator, enter a value, and apply — all in a focused overlay. +- **Hover percentages on dashboard panels.** Hover over any item in Content, Locations, Technology, or Top Referrers to see what percentage of total traffic it represents. The percentage slides in smoothly next to the count. +- **Click any item to filter your dashboard.** Click a referrer, browser, country, page, or any other item in your dashboard panels to instantly filter the entire dashboard to just that traffic. +- **New filter experience.** A single compact "Filter" button replaces the old filter UI. Click it to browse all available dimensions, see real values from your data with visitor counts, search or type a custom value, and apply — all in a quick dropdown without leaving the page. + +### Improved + +- **Stronger filter pills.** Active filters now use solid brand-colored pills that are easy to spot in both light and dark mode. Click any pill to remove it. ### Fixed -- **Clicking the same item no longer creates duplicate filters.** Previously, clicking the same referrer or browser multiple times would stack identical filter pills. Now the dashboard recognizes you already have that filter active and ignores the duplicate. -- **"Direct" traffic can now be filtered.** Typing "Direct" as a referrer filter value now correctly matches visitors who arrived without a referrer. Previously this showed zero results because the system didn't recognize "Direct" as a special value. -- **Campaigns now respect active filters.** The Campaigns panel previously ignored your active filters — so if you filtered by a specific referrer or country, campaigns still showed all data. Campaigns now filter along with the rest of your dashboard. +- **Duplicate filters no longer stack.** Clicking the same item twice no longer adds the same filter again. +- **Campaigns now respect your active filters.** Previously, the Campaigns panel ignored dashboard filters and always showed all campaigns. Now it filters along with everything else. +- **Duplicate "Direct" entry removed from the referrer filter list.** The referrer suggestions no longer show "Direct" twice. +- **Filter dropdowns now show all your data.** Previously, the filter value list only showed up to 10 items — so if you had 50 cities or 30 browsers, most were missing. Now up to 100 values are loaded when you open a filter, with a loading spinner while they're fetched. ## [0.13.0-alpha] - 2026-03-02 diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index f9b0fb3..c115c85 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -5,7 +5,20 @@ import { logger } from '@/lib/utils/logger' import { useCallback, useEffect, useState, useMemo } from 'react' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { motion } from 'framer-motion' -import { getPerformanceByPage, type Stats, type DailyStat } from '@/lib/api/stats' +import { + getPerformanceByPage, + getTopPages, + getTopReferrers, + getCountries, + getCities, + getRegions, + getBrowsers, + getOS, + getDevices, + getCampaigns, + type Stats, + type DailyStat, +} from '@/lib/api/stats' import { getDateRange } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui' import { Button } from '@ciphera-net/ui' @@ -22,7 +35,7 @@ import GoalStats from '@/components/dashboard/GoalStats' import ScrollDepth from '@/components/dashboard/ScrollDepth' import Campaigns from '@/components/dashboard/Campaigns' import FilterBar from '@/components/dashboard/FilterBar' -import AddFilterDropdown, { type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown' +import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown' import EventProperties from '@/components/dashboard/EventProperties' import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters' import { @@ -98,17 +111,12 @@ export default function SiteDashboardPage() { const [selectedEvent, setSelectedEvent] = useState(null) const handleAddFilter = useCallback((filter: DimensionFilter) => { - // Normalize "Direct" referrer to empty string (direct traffic has no referrer in DB) - const normalized = { ...filter } - if (normalized.dimension === 'referrer') { - normalized.values = normalized.values.map(v => v.toLowerCase() === 'direct' ? '' : v) - } setFilters(prev => { const isDuplicate = prev.some( - f => f.dimension === normalized.dimension && f.operator === normalized.operator && f.values.join(';') === normalized.values.join(';') + f => f.dimension === filter.dimension && f.operator === filter.operator && f.values.join(';') === filter.values.join(';') ) if (isDuplicate) return prev - return [...prev, normalized] + return [...prev, filter] }) }, []) @@ -120,6 +128,69 @@ export default function SiteDashboardPage() { setFilters([]) }, []) + // Fetch full suggestion list (up to 100) when a dimension is selected in the filter dropdown + const handleFetchSuggestions = useCallback(async (dimension: string): Promise => { + const start = dateRange.start + const end = dateRange.end + const f = filtersParam || undefined + const limit = 100 + + try { + const regionNames = (() => { try { return new Intl.DisplayNames(['en'], { type: 'region' }) } catch { return null } })() + + switch (dimension) { + case 'page': { + const data = await getTopPages(siteId, start, end, limit, f) + return data.map(p => ({ value: p.path, label: p.path, count: p.pageviews })) + } + case 'referrer': { + const data = await getTopReferrers(siteId, start, end, limit, f) + return data.filter(r => r.referrer && r.referrer !== '').map(r => ({ value: r.referrer, label: r.referrer, count: r.pageviews })) + } + case 'country': { + const data = await getCountries(siteId, start, end, limit, f) + return data.filter(c => c.country && c.country !== 'Unknown').map(c => ({ value: c.country, label: regionNames?.of(c.country) ?? c.country, count: c.pageviews })) + } + case 'city': { + const data = await getCities(siteId, start, end, limit, f) + return data.filter(c => c.city && c.city !== 'Unknown').map(c => ({ value: c.city, label: c.city, count: c.pageviews })) + } + case 'region': { + const data = await getRegions(siteId, start, end, limit, f) + return data.filter(r => r.region && r.region !== 'Unknown').map(r => ({ value: r.region, label: r.region, count: r.pageviews })) + } + case 'browser': { + const data = await getBrowsers(siteId, start, end, limit, f) + return data.filter(b => b.browser && b.browser !== 'Unknown').map(b => ({ value: b.browser, label: b.browser, count: b.pageviews })) + } + case 'os': { + const data = await getOS(siteId, start, end, limit, f) + return data.filter(o => o.os && o.os !== 'Unknown').map(o => ({ value: o.os, label: o.os, count: o.pageviews })) + } + case 'device': { + const data = await getDevices(siteId, start, end, limit, f) + return data.filter(d => d.device && d.device !== 'Unknown').map(d => ({ value: d.device, label: d.device, count: d.pageviews })) + } + case 'utm_source': + case 'utm_medium': + case 'utm_campaign': { + const data = await getCampaigns(siteId, start, end, limit, f) + const map = new Map() + const field = dimension === 'utm_source' ? 'source' : dimension === 'utm_medium' ? 'medium' : 'campaign' + data.forEach(c => { + const val = c[field] + if (val) map.set(val, (map.get(val) ?? 0) + c.pageviews) + }) + return [...map.entries()].map(([v, count]) => ({ value: v, label: v, count })) + } + default: + return [] + } + } catch { + return [] + } + }, [siteId, dateRange.start, dateRange.end, filtersParam]) + // Sync filters to URL useEffect(() => { const url = new URL(window.location.href) @@ -181,14 +252,11 @@ export default function SiteDashboardPage() { // Referrers const refs = referrers?.top_referrers ?? [] if (refs.length > 0) { - s.referrer = [ - { value: '', label: 'Direct', count: undefined }, - ...refs.filter(r => r.referrer && r.referrer !== '').map(r => ({ - value: r.referrer, - label: r.referrer, - count: r.pageviews, - })), - ] + s.referrer = refs.filter(r => r.referrer && r.referrer !== '').map(r => ({ + value: r.referrer, + label: r.referrer, + count: r.pageviews, + })) } // Countries @@ -461,7 +529,7 @@ export default function SiteDashboardPage() { {/* Dimension Filters */}
- +
diff --git a/components/dashboard/AddFilterDropdown.tsx b/components/dashboard/AddFilterDropdown.tsx index e0068ce..6f70435 100644 --- a/components/dashboard/AddFilterDropdown.tsx +++ b/components/dashboard/AddFilterDropdown.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useCallback } from 'react' import { DIMENSION_LABELS, OPERATORS, OPERATOR_LABELS, type DimensionFilter } from '@/lib/filters' export interface FilterSuggestion { @@ -16,15 +16,18 @@ export interface FilterSuggestions { interface AddFilterDropdownProps { onAdd: (filter: DimensionFilter) => void suggestions?: FilterSuggestions + onFetchSuggestions?: (dimension: string) => Promise } const ALL_DIMS = ['page', 'referrer', 'country', 'region', 'city', 'browser', 'os', 'device', 'utm_source', 'utm_medium', 'utm_campaign'] -export default function AddFilterDropdown({ onAdd, suggestions = {} }: AddFilterDropdownProps) { +export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSuggestions }: AddFilterDropdownProps) { const [isOpen, setIsOpen] = useState(false) const [selectedDim, setSelectedDim] = useState(null) const [operator, setOperator] = useState('is') const [search, setSearch] = useState('') + const [fetchedSuggestions, setFetchedSuggestions] = useState([]) + const [isFetching, setIsFetching] = useState(false) const ref = useRef(null) const inputRef = useRef(null) @@ -52,12 +55,32 @@ export default function AddFilterDropdown({ onAdd, suggestions = {} }: AddFilter if (selectedDim) inputRef.current?.focus() }, [selectedDim]) - function handleClose() { + // Fetch full suggestions when a dimension is selected + useEffect(() => { + if (!selectedDim || !onFetchSuggestions) { + setFetchedSuggestions([]) + return + } + let cancelled = false + setIsFetching(true) + onFetchSuggestions(selectedDim).then(data => { + if (!cancelled) { + setFetchedSuggestions(data) + setIsFetching(false) + } + }).catch(() => { + if (!cancelled) setIsFetching(false) + }) + return () => { cancelled = true } + }, [selectedDim, onFetchSuggestions]) + + const handleClose = useCallback(() => { setIsOpen(false) setSelectedDim(null) setOperator('is') setSearch('') - } + setFetchedSuggestions([]) + }, []) function handleSelectValue(value: string) { onAdd({ dimension: selectedDim!, operator, values: [value] }) @@ -70,7 +93,10 @@ export default function AddFilterDropdown({ onAdd, suggestions = {} }: AddFilter handleClose() } - const dimSuggestions = selectedDim ? (suggestions[selectedDim] || []) : [] + // Use fetched data if available, fall back to prop suggestions + const dimSuggestions = selectedDim + ? (fetchedSuggestions.length > 0 ? fetchedSuggestions : (suggestions[selectedDim] || [])) + : [] const filtered = dimSuggestions.filter(s => s.label.toLowerCase().includes(search.toLowerCase()) || s.value.toLowerCase().includes(search.toLowerCase()) @@ -118,7 +144,7 @@ export default function AddFilterDropdown({ onAdd, suggestions = {} }: AddFilter {/* Header with back button */}
{/* Values list */} - {filtered.length > 0 && ( + {isFetching ? ( +
+
+
+ ) : filtered.length > 0 ? (
{filtered.map(s => (
- )} - - {/* Custom value apply */} - {search.trim() && filtered.length === 0 && ( + ) : search.trim() ? (
- )} + ) : null} )}
)} ) -} \ No newline at end of file +} diff --git a/lib/filters.ts b/lib/filters.ts index 608083a..e6fbac2 100644 --- a/lib/filters.ts +++ b/lib/filters.ts @@ -55,8 +55,6 @@ export function parseFiltersFromURL(raw: string): DimensionFilter[] { export function filterLabel(f: DimensionFilter): string { const dim = DIMENSION_LABELS[f.dimension] || f.dimension const op = OPERATOR_LABELS[f.operator] || f.operator - const rawVal = f.values.length > 1 ? `${f.values[0]} +${f.values.length - 1}` : f.values[0] - // Show "Direct" for empty referrer values (direct traffic has no referrer in DB) - const val = f.dimension === 'referrer' && rawVal === '' ? 'Direct' : rawVal + const val = f.values.length > 1 ? `${f.values[0]} +${f.values.length - 1}` : f.values[0] return `${dim} ${op} ${val}` }