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 <noreply@anthropic.com>
This commit is contained in:
17
CHANGELOG.md
17
CHANGELOG.md
@@ -10,15 +10,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
### Added
|
### 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.
|
- **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.** 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.
|
- **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.
|
||||||
- **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.
|
- **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
|
### 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.
|
- **Duplicate filters no longer stack.** Clicking the same item twice no longer adds the same filter again.
|
||||||
- **"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 your active filters.** Previously, the Campaigns panel ignored dashboard filters and always showed all campaigns. Now it filters along with everything else.
|
||||||
- **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 "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
|
## [0.13.0-alpha] - 2026-03-02
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,20 @@ import { logger } from '@/lib/utils/logger'
|
|||||||
import { useCallback, useEffect, useState, useMemo } from 'react'
|
import { useCallback, useEffect, useState, useMemo } from 'react'
|
||||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { motion } from 'framer-motion'
|
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 { getDateRange } from '@ciphera-net/ui'
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
import { Button } 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 ScrollDepth from '@/components/dashboard/ScrollDepth'
|
||||||
import Campaigns from '@/components/dashboard/Campaigns'
|
import Campaigns from '@/components/dashboard/Campaigns'
|
||||||
import FilterBar from '@/components/dashboard/FilterBar'
|
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 EventProperties from '@/components/dashboard/EventProperties'
|
||||||
import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters'
|
import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters'
|
||||||
import {
|
import {
|
||||||
@@ -98,17 +111,12 @@ export default function SiteDashboardPage() {
|
|||||||
const [selectedEvent, setSelectedEvent] = useState<string | null>(null)
|
const [selectedEvent, setSelectedEvent] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleAddFilter = useCallback((filter: DimensionFilter) => {
|
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 => {
|
setFilters(prev => {
|
||||||
const isDuplicate = prev.some(
|
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
|
if (isDuplicate) return prev
|
||||||
return [...prev, normalized]
|
return [...prev, filter]
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -120,6 +128,69 @@ export default function SiteDashboardPage() {
|
|||||||
setFilters([])
|
setFilters([])
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Fetch full suggestion list (up to 100) when a dimension is selected in the filter dropdown
|
||||||
|
const handleFetchSuggestions = useCallback(async (dimension: string): Promise<FilterSuggestion[]> => {
|
||||||
|
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<string, number>()
|
||||||
|
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
|
// Sync filters to URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
@@ -181,14 +252,11 @@ export default function SiteDashboardPage() {
|
|||||||
// Referrers
|
// Referrers
|
||||||
const refs = referrers?.top_referrers ?? []
|
const refs = referrers?.top_referrers ?? []
|
||||||
if (refs.length > 0) {
|
if (refs.length > 0) {
|
||||||
s.referrer = [
|
s.referrer = refs.filter(r => r.referrer && r.referrer !== '').map(r => ({
|
||||||
{ value: '', label: 'Direct', count: undefined },
|
value: r.referrer,
|
||||||
...refs.filter(r => r.referrer && r.referrer !== '').map(r => ({
|
label: r.referrer,
|
||||||
value: r.referrer,
|
count: r.pageviews,
|
||||||
label: r.referrer,
|
}))
|
||||||
count: r.pageviews,
|
|
||||||
})),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Countries
|
// Countries
|
||||||
@@ -461,7 +529,7 @@ export default function SiteDashboardPage() {
|
|||||||
|
|
||||||
{/* Dimension Filters */}
|
{/* Dimension Filters */}
|
||||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||||
<AddFilterDropdown onAdd={handleAddFilter} suggestions={filterSuggestions} />
|
<AddFilterDropdown onAdd={handleAddFilter} suggestions={filterSuggestions} onFetchSuggestions={handleFetchSuggestions} />
|
||||||
<FilterBar filters={filters} onRemove={handleRemoveFilter} onClear={handleClearFilters} />
|
<FilterBar filters={filters} onRemove={handleRemoveFilter} onClear={handleClearFilters} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'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'
|
import { DIMENSION_LABELS, OPERATORS, OPERATOR_LABELS, type DimensionFilter } from '@/lib/filters'
|
||||||
|
|
||||||
export interface FilterSuggestion {
|
export interface FilterSuggestion {
|
||||||
@@ -16,15 +16,18 @@ export interface FilterSuggestions {
|
|||||||
interface AddFilterDropdownProps {
|
interface AddFilterDropdownProps {
|
||||||
onAdd: (filter: DimensionFilter) => void
|
onAdd: (filter: DimensionFilter) => void
|
||||||
suggestions?: FilterSuggestions
|
suggestions?: FilterSuggestions
|
||||||
|
onFetchSuggestions?: (dimension: string) => Promise<FilterSuggestion[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_DIMS = ['page', 'referrer', 'country', 'region', 'city', 'browser', 'os', 'device', 'utm_source', 'utm_medium', 'utm_campaign']
|
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 [isOpen, setIsOpen] = useState(false)
|
||||||
const [selectedDim, setSelectedDim] = useState<string | null>(null)
|
const [selectedDim, setSelectedDim] = useState<string | null>(null)
|
||||||
const [operator, setOperator] = useState<DimensionFilter['operator']>('is')
|
const [operator, setOperator] = useState<DimensionFilter['operator']>('is')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
const [fetchedSuggestions, setFetchedSuggestions] = useState<FilterSuggestion[]>([])
|
||||||
|
const [isFetching, setIsFetching] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@@ -52,12 +55,32 @@ export default function AddFilterDropdown({ onAdd, suggestions = {} }: AddFilter
|
|||||||
if (selectedDim) inputRef.current?.focus()
|
if (selectedDim) inputRef.current?.focus()
|
||||||
}, [selectedDim])
|
}, [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)
|
setIsOpen(false)
|
||||||
setSelectedDim(null)
|
setSelectedDim(null)
|
||||||
setOperator('is')
|
setOperator('is')
|
||||||
setSearch('')
|
setSearch('')
|
||||||
}
|
setFetchedSuggestions([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
function handleSelectValue(value: string) {
|
function handleSelectValue(value: string) {
|
||||||
onAdd({ dimension: selectedDim!, operator, values: [value] })
|
onAdd({ dimension: selectedDim!, operator, values: [value] })
|
||||||
@@ -70,7 +93,10 @@ export default function AddFilterDropdown({ onAdd, suggestions = {} }: AddFilter
|
|||||||
handleClose()
|
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 =>
|
const filtered = dimSuggestions.filter(s =>
|
||||||
s.label.toLowerCase().includes(search.toLowerCase()) ||
|
s.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
s.value.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 */}
|
{/* Header with back button */}
|
||||||
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
|
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setSelectedDim(null); setSearch(''); setOperator('is') }}
|
onClick={() => { setSelectedDim(null); setSearch(''); setOperator('is'); setFetchedSuggestions([]) }}
|
||||||
className="p-1 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors cursor-pointer rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
className="p-1 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors cursor-pointer rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
@@ -168,7 +194,11 @@ export default function AddFilterDropdown({ onAdd, suggestions = {} }: AddFilter
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Values list */}
|
{/* Values list */}
|
||||||
{filtered.length > 0 && (
|
{isFetching ? (
|
||||||
|
<div className="px-4 py-6 text-center">
|
||||||
|
<div className="inline-block w-4 h-4 border-2 border-neutral-300 dark:border-neutral-600 border-t-brand-orange rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : filtered.length > 0 ? (
|
||||||
<div className="max-h-52 overflow-y-auto border-t border-neutral-100 dark:border-neutral-800">
|
<div className="max-h-52 overflow-y-auto border-t border-neutral-100 dark:border-neutral-800">
|
||||||
{filtered.map(s => (
|
{filtered.map(s => (
|
||||||
<button
|
<button
|
||||||
@@ -185,10 +215,7 @@ export default function AddFilterDropdown({ onAdd, suggestions = {} }: AddFilter
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : search.trim() ? (
|
||||||
|
|
||||||
{/* Custom value apply */}
|
|
||||||
{search.trim() && filtered.length === 0 && (
|
|
||||||
<div className="px-3 py-3 border-t border-neutral-100 dark:border-neutral-800">
|
<div className="px-3 py-3 border-t border-neutral-100 dark:border-neutral-800">
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmitCustom}
|
onClick={handleSubmitCustom}
|
||||||
@@ -197,11 +224,11 @@ export default function AddFilterDropdown({ onAdd, suggestions = {} }: AddFilter
|
|||||||
Filter by “{search.trim()}”
|
Filter by “{search.trim()}”
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,8 +55,6 @@ export function parseFiltersFromURL(raw: string): DimensionFilter[] {
|
|||||||
export function filterLabel(f: DimensionFilter): string {
|
export function filterLabel(f: DimensionFilter): string {
|
||||||
const dim = DIMENSION_LABELS[f.dimension] || f.dimension
|
const dim = DIMENSION_LABELS[f.dimension] || f.dimension
|
||||||
const op = OPERATOR_LABELS[f.operator] || f.operator
|
const op = OPERATOR_LABELS[f.operator] || f.operator
|
||||||
const rawVal = f.values.length > 1 ? `${f.values[0]} +${f.values.length - 1}` : f.values[0]
|
const val = 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
|
|
||||||
return `${dim} ${op} ${val}`
|
return `${dim} ${op} ${val}`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user