From 5677f30f3ba8a5364d867101360ac45f756003b2 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 6 Mar 2026 21:02:14 +0100 Subject: [PATCH] feat: add dashboard dimension filtering and custom event properties Dashboard filtering: FilterBar pills, AddFilterDropdown with dimension/ operator/value steps, URL-serialized filters, all SWR hooks filter-aware. Custom event properties: pulse.track() accepts props object, EventProperties panel with auto-discovered key tabs and value bar charts, clickable goal rows. Updated changelog with both features under v0.13.0-alpha. --- CHANGELOG.md | 4 +- app/sites/[id]/page.tsx | 80 ++++++++++++-- components/dashboard/AddFilterDropdown.tsx | 119 +++++++++++++++++++++ components/dashboard/EventProperties.tsx | 108 +++++++++++++++++++ components/dashboard/FilterBar.tsx | 42 ++++++++ components/dashboard/GoalStats.tsx | 6 +- lib/api/stats.ts | 64 +++++++---- lib/filters.ts | 60 +++++++++++ lib/swr/dashboard.ts | 64 +++++------ public/script.js | 16 ++- 10 files changed, 497 insertions(+), 66 deletions(-) create mode 100644 components/dashboard/AddFilterDropdown.tsx create mode 100644 components/dashboard/EventProperties.tsx create mode 100644 components/dashboard/FilterBar.tsx create mode 100644 lib/filters.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a8d4725..45fb3b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- **Dashboard filtering.** You can now filter your entire dashboard by any dimension — browser, country, page, device, OS, referrer, or UTM parameters. Click "Add filter" to pick a dimension, choose an operator (is, is not, contains, does not contain), and enter a value. Active filters appear as removable pills above your charts. You can also stack multiple filters to narrow down exactly the traffic you're looking at. Filter selections are saved in the URL, so you can bookmark or share a filtered view with your team. +- **Custom event properties.** Your custom events can now carry extra context. For example, tracking a signup with `pulse.track('signup', { plan: 'pro', source: 'landing' })` records the plan and source alongside the event. Click any event in your Goals & Events panel to see a breakdown of its properties and values — no setup or registration needed. This helps you understand not just what happened, but why. - **AI traffic source identification.** Pulse now automatically recognizes visitors coming from AI tools — ChatGPT, Perplexity, Claude, Gemini, Copilot, DeepSeek, Grok, Meta AI, You.com, and Phind. These sources appear in your Top Referrers with proper brand icons and display names instead of raw domain URLs. If someone clicks a link in an AI chat to visit your site, you'll see exactly which AI tool sent them. - **Automatic outbound link tracking.** Pulse now tracks when visitors click links that take them to other websites. These show up as "outbound link" events in your Goals & Events panel — no setup needed. You can turn this off in your tracking snippet settings if you prefer. - **Automatic file download tracking.** When a visitor clicks a link to a downloadable file — PDF, ZIP, Excel, Word, MP3, and 20+ other formats — Pulse records it as a "file download" event. Like outbound links, this works automatically with no setup required. @@ -294,7 +296,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), --- [Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.13.0-alpha...HEAD -[0.13.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.12.0-alpha...v0.13.0-alpha +[0.13.0-alpha]: https://github.com/ciphera-net/pulse/releases/tag/v0.13.0-alpha [0.12.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.11.1-alpha...v0.12.0-alpha [0.11.1-alpha]: https://github.com/ciphera-net/pulse/compare/v0.11.0-alpha...v0.11.1-alpha [0.11.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.10.0-alpha...v0.11.0-alpha diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index 8651173..36532c0 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -2,8 +2,8 @@ import { useAuth } from '@/lib/auth/context' import { logger } from '@/lib/utils/logger' -import { useEffect, useState, useMemo } from 'react' -import { useParams, useRouter } from 'next/navigation' +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 { getDateRange } from '@ciphera-net/ui' @@ -21,6 +21,10 @@ import PerformanceStats from '@/components/dashboard/PerformanceStats' 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 from '@/components/dashboard/AddFilterDropdown' +import EventProperties from '@/components/dashboard/EventProperties' +import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters' import { useDashboardOverview, useDashboardPages, @@ -82,6 +86,40 @@ export default function SiteDashboardPage() { const [lastUpdatedAt, setLastUpdatedAt] = useState(null) const [, setTick] = useState(0) + // Dimension filters state + const searchParams = useSearchParams() + const [filters, setFilters] = useState(() => { + const raw = searchParams.get('filters') + return raw ? parseFiltersFromURL(raw) : [] + }) + const filtersParam = useMemo(() => serializeFilters(filters), [filters]) + + // Selected event for property breakdown + const [selectedEvent, setSelectedEvent] = useState(null) + + const handleAddFilter = useCallback((filter: DimensionFilter) => { + setFilters(prev => [...prev, filter]) + }, []) + + const handleRemoveFilter = useCallback((index: number) => { + setFilters(prev => prev.filter((_, i) => i !== index)) + }, []) + + const handleClearFilters = useCallback(() => { + setFilters([]) + }, []) + + // Sync filters to URL + useEffect(() => { + const url = new URL(window.location.href) + if (filtersParam) { + url.searchParams.set('filters', filtersParam) + } else { + url.searchParams.delete('filters') + } + window.history.replaceState({}, '', url.toString()) + }, [filtersParam]) + const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval // Previous period date range for comparison @@ -100,13 +138,14 @@ export default function SiteDashboardPage() { // SWR hooks - replace manual useState + useEffect + setInterval polling // Each hook handles its own refresh interval, deduplication, and error retry - const { data: overview, isLoading: overviewLoading, error: overviewError } = useDashboardOverview(siteId, dateRange.start, dateRange.end, interval) - const { data: pages } = useDashboardPages(siteId, dateRange.start, dateRange.end) - const { data: locations } = useDashboardLocations(siteId, dateRange.start, dateRange.end) - const { data: devicesData } = useDashboardDevices(siteId, dateRange.start, dateRange.end) - const { data: referrers } = useDashboardReferrers(siteId, dateRange.start, dateRange.end) - const { data: performanceData } = useDashboardPerformance(siteId, dateRange.start, dateRange.end) - const { data: goalsData } = useDashboardGoals(siteId, dateRange.start, dateRange.end) + // Filters are included in cache keys so changing filters auto-refetches + const { data: overview, isLoading: overviewLoading, error: overviewError } = useDashboardOverview(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined) + const { data: pages } = useDashboardPages(siteId, dateRange.start, dateRange.end, filtersParam || undefined) + const { data: locations } = useDashboardLocations(siteId, dateRange.start, dateRange.end, filtersParam || undefined) + const { data: devicesData } = useDashboardDevices(siteId, dateRange.start, dateRange.end, filtersParam || undefined) + const { data: referrers } = useDashboardReferrers(siteId, dateRange.start, dateRange.end, filtersParam || undefined) + const { data: performanceData } = useDashboardPerformance(siteId, dateRange.start, dateRange.end, filtersParam || undefined) + const { data: goalsData } = useDashboardGoals(siteId, dateRange.start, dateRange.end, filtersParam || undefined) const { data: realtimeData } = useRealtime(siteId) const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end) const { data: prevDailyStats } = useDailyStats(siteId, prevRange.start, prevRange.end, interval) @@ -306,6 +345,12 @@ export default function SiteDashboardPage() { + {/* Dimension Filters */} +
+ + +
+ {/* Advanced Chart with Integrated Stats */}
- !/^scroll_\d+$/.test(g.event_name))} /> + !/^scroll_\d+$/.test(g.event_name))} + onSelectEvent={setSelectedEvent} + />
+ {/* Event Properties Breakdown */} + {selectedEvent && ( +
+ setSelectedEvent(null)} + /> +
+ )} + setIsDatePickerOpen(false)} diff --git a/components/dashboard/AddFilterDropdown.tsx b/components/dashboard/AddFilterDropdown.tsx new file mode 100644 index 0000000..5207bf0 --- /dev/null +++ b/components/dashboard/AddFilterDropdown.tsx @@ -0,0 +1,119 @@ +'use client' + +import { useState, useRef, useEffect } from 'react' +import { DIMENSIONS, DIMENSION_LABELS, OPERATORS, OPERATOR_LABELS, type DimensionFilter } from '@/lib/filters' + +interface AddFilterDropdownProps { + onAdd: (filter: DimensionFilter) => void +} + +type Step = 'dimension' | 'operator' | 'value' + +export default function AddFilterDropdown({ onAdd }: AddFilterDropdownProps) { + const [isOpen, setIsOpen] = useState(false) + const [step, setStep] = useState('dimension') + const [dimension, setDimension] = useState('') + const [operator, setOperator] = useState('is') + const [value, setValue] = useState('') + const ref = useRef(null) + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setIsOpen(false) + resetState() + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + function resetState() { + setStep('dimension') + setDimension('') + setOperator('is') + setValue('') + } + + function handleSubmit() { + if (!dimension || !operator || !value.trim()) return + onAdd({ dimension, operator, values: [value.trim()] }) + setIsOpen(false) + resetState() + } + + return ( +
+ + + {isOpen && ( +
+ {step === 'dimension' && ( +
+
+ Select dimension +
+ {DIMENSIONS.map(dim => ( + + ))} +
+ )} + + {step === 'operator' && ( +
+
+ {DIMENSION_LABELS[dimension]} ... +
+ {OPERATORS.map(op => ( + + ))} +
+ )} + + {step === 'value' && ( +
+
+ {DIMENSION_LABELS[dimension]} {OPERATOR_LABELS[operator]} +
+ setValue(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleSubmit() }} + placeholder="Enter value..." + className="w-full px-3 py-2 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange/30" + /> + +
+ )} +
+ )} +
+ ) +} diff --git a/components/dashboard/EventProperties.tsx b/components/dashboard/EventProperties.tsx new file mode 100644 index 0000000..4adc359 --- /dev/null +++ b/components/dashboard/EventProperties.tsx @@ -0,0 +1,108 @@ +'use client' + +import { useState, useEffect } from 'react' +import { formatNumber } from '@ciphera-net/ui' +import { getEventPropertyKeys, getEventPropertyValues, type EventPropertyKey, type EventPropertyValue } from '@/lib/api/stats' + +interface EventPropertiesProps { + siteId: string + eventName: string + dateRange: { start: string; end: string } + onClose: () => void +} + +export default function EventProperties({ siteId, eventName, dateRange, onClose }: EventPropertiesProps) { + const [keys, setKeys] = useState([]) + const [selectedKey, setSelectedKey] = useState(null) + const [values, setValues] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + setLoading(true) + getEventPropertyKeys(siteId, eventName, dateRange.start, dateRange.end) + .then(k => { + setKeys(k) + if (k.length > 0) setSelectedKey(k[0].key) + }) + .finally(() => setLoading(false)) + }, [siteId, eventName, dateRange.start, dateRange.end]) + + useEffect(() => { + if (!selectedKey) return + getEventPropertyValues(siteId, eventName, selectedKey, dateRange.start, dateRange.end) + .then(setValues) + }, [siteId, eventName, selectedKey, dateRange.start, dateRange.end]) + + const maxCount = values.length > 0 ? values[0].count : 1 + + return ( +
+
+

+ Properties: {eventName.replace(/_/g, ' ')} +

+ +
+ + {loading ? ( +
+ {[1, 2, 3].map(i => ( +
+ ))} +
+ ) : keys.length === 0 ? ( +

+ No properties recorded for this event yet. +

+ ) : ( + <> +
+ {keys.map(k => ( + + ))} +
+ +
+ {values.map(v => ( +
+
+
+ + {v.value} + + + {formatNumber(v.count)} + +
+
+
+
+
+
+ ))} +
+ + )} +
+ ) +} diff --git a/components/dashboard/FilterBar.tsx b/components/dashboard/FilterBar.tsx new file mode 100644 index 0000000..fd8a1b5 --- /dev/null +++ b/components/dashboard/FilterBar.tsx @@ -0,0 +1,42 @@ +'use client' + +import { type DimensionFilter, filterLabel } from '@/lib/filters' + +interface FilterBarProps { + filters: DimensionFilter[] + onRemove: (index: number) => void + onClear: () => void +} + +export default function FilterBar({ filters, onRemove, onClear }: FilterBarProps) { + if (filters.length === 0) return null + + return ( +
+ + Filters + + {filters.map((f, i) => ( + + ))} + {filters.length > 1 && ( + + )} +
+ ) +} diff --git a/components/dashboard/GoalStats.tsx b/components/dashboard/GoalStats.tsx index f3f6b06..3207393 100644 --- a/components/dashboard/GoalStats.tsx +++ b/components/dashboard/GoalStats.tsx @@ -7,11 +7,12 @@ import type { GoalCountStat } from '@/lib/api/stats' interface GoalStatsProps { goalCounts: GoalCountStat[] + onSelectEvent?: (eventName: string) => void } const LIMIT = 10 -export default function GoalStats({ goalCounts }: GoalStatsProps) { +export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps) { const list = (goalCounts || []).slice(0, LIMIT) const hasData = list.length > 0 @@ -28,7 +29,8 @@ export default function GoalStats({ goalCounts }: GoalStatsProps) { {list.map((row) => (
onSelectEvent?.(row.event_name)} + className={`flex items-center justify-between py-2 px-3 rounded-lg bg-neutral-50 dark:bg-neutral-800/50 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors${onSelectEvent ? ' cursor-pointer' : ''}`} > {row.display_name ?? row.event_name.replace(/_/g, ' ')} diff --git a/lib/api/stats.ts b/lib/api/stats.ts index 250af50..5e66122 100644 --- a/lib/api/stats.ts +++ b/lib/api/stats.ts @@ -120,6 +120,7 @@ function buildQuery( interval?: string countryLimit?: number sort?: string + filters?: string }, auth?: AuthParams ): string { @@ -130,6 +131,7 @@ function buildQuery( if (opts.interval) params.append('interval', opts.interval) if (opts.countryLimit != null) params.append('country_limit', opts.countryLimit.toString()) if (opts.sort) params.append('sort', opts.sort) + if (opts.filters) params.append('filters', opts.filters) if (auth) appendAuthParams(params, auth) const query = params.toString() return query ? `?${query}` : '' @@ -137,8 +139,8 @@ function buildQuery( /** Factory for endpoints that return an array nested under a response key. */ function createListFetcher(path: string, field: string, defaultLimit = 10) { - return (siteId: string, startDate?: string, endDate?: string, limit = defaultLimit): Promise => - apiRequest>(`/sites/${siteId}/${path}${buildQuery({ startDate, endDate, limit })}`) + return (siteId: string, startDate?: string, endDate?: string, limit = defaultLimit, filters?: string): Promise => + apiRequest>(`/sites/${siteId}/${path}${buildQuery({ startDate, endDate, limit, filters })}`) .then(r => r?.[field] || []) } @@ -160,8 +162,8 @@ export const getCampaigns = createListFetcher('campaigns', 'campai // ─── Stats & Realtime ─────────────────────────────────────────────── -export function getStats(siteId: string, startDate?: string, endDate?: string): Promise { - return apiRequest(`/sites/${siteId}/stats${buildQuery({ startDate, endDate })}`) +export function getStats(siteId: string, startDate?: string, endDate?: string, filters?: string): Promise { + return apiRequest(`/sites/${siteId}/stats${buildQuery({ startDate, endDate, filters })}`) } export function getPublicStats(siteId: string, startDate?: string, endDate?: string, auth?: AuthParams): Promise { @@ -178,8 +180,8 @@ export function getPublicRealtime(siteId: string, auth?: AuthParams): Promise { - return apiRequest<{ stats: DailyStat[] }>(`/sites/${siteId}/daily${buildQuery({ startDate, endDate, interval })}`) +export function getDailyStats(siteId: string, startDate?: string, endDate?: string, interval?: string, filters?: string): Promise { + return apiRequest<{ stats: DailyStat[] }>(`/sites/${siteId}/daily${buildQuery({ startDate, endDate, interval, filters })}`) .then(r => r?.stats || []) } @@ -302,8 +304,8 @@ export interface DashboardGoalsData { goal_counts: GoalCountStat[] } -export function getDashboardOverview(siteId: string, startDate?: string, endDate?: string, interval?: string): Promise { - return apiRequest(`/sites/${siteId}/dashboard/overview${buildQuery({ startDate, endDate, interval })}`) +export function getDashboardOverview(siteId: string, startDate?: string, endDate?: string, interval?: string, filters?: string): Promise { + return apiRequest(`/sites/${siteId}/dashboard/overview${buildQuery({ startDate, endDate, interval, filters })}`) } export function getPublicDashboardOverview( @@ -313,8 +315,8 @@ export function getPublicDashboardOverview( return apiRequest(`/public/sites/${siteId}/dashboard/overview${buildQuery({ startDate, endDate, interval }, { password, captcha })}`) } -export function getDashboardPages(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise { - return apiRequest(`/sites/${siteId}/dashboard/pages${buildQuery({ startDate, endDate, limit })}`) +export function getDashboardPages(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise { + return apiRequest(`/sites/${siteId}/dashboard/pages${buildQuery({ startDate, endDate, limit, filters })}`) } export function getPublicDashboardPages( @@ -324,8 +326,8 @@ export function getPublicDashboardPages( return apiRequest(`/public/sites/${siteId}/dashboard/pages${buildQuery({ startDate, endDate, limit }, { password, captcha })}`) } -export function getDashboardLocations(siteId: string, startDate?: string, endDate?: string, limit = 10, countryLimit = 250): Promise { - return apiRequest(`/sites/${siteId}/dashboard/locations${buildQuery({ startDate, endDate, limit, countryLimit })}`) +export function getDashboardLocations(siteId: string, startDate?: string, endDate?: string, limit = 10, countryLimit = 250, filters?: string): Promise { + return apiRequest(`/sites/${siteId}/dashboard/locations${buildQuery({ startDate, endDate, limit, countryLimit, filters })}`) } export function getPublicDashboardLocations( @@ -335,8 +337,8 @@ export function getPublicDashboardLocations( return apiRequest(`/public/sites/${siteId}/dashboard/locations${buildQuery({ startDate, endDate, limit, countryLimit }, { password, captcha })}`) } -export function getDashboardDevices(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise { - return apiRequest(`/sites/${siteId}/dashboard/devices${buildQuery({ startDate, endDate, limit })}`) +export function getDashboardDevices(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise { + return apiRequest(`/sites/${siteId}/dashboard/devices${buildQuery({ startDate, endDate, limit, filters })}`) } export function getPublicDashboardDevices( @@ -346,8 +348,8 @@ export function getPublicDashboardDevices( return apiRequest(`/public/sites/${siteId}/dashboard/devices${buildQuery({ startDate, endDate, limit }, { password, captcha })}`) } -export function getDashboardReferrers(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise { - return apiRequest(`/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit })}`) +export function getDashboardReferrers(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise { + return apiRequest(`/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit, filters })}`) } export function getPublicDashboardReferrers( @@ -357,8 +359,8 @@ export function getPublicDashboardReferrers( return apiRequest(`/public/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit }, { password, captcha })}`) } -export function getDashboardPerformance(siteId: string, startDate?: string, endDate?: string): Promise { - return apiRequest(`/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate })}`) +export function getDashboardPerformance(siteId: string, startDate?: string, endDate?: string, filters?: string): Promise { + return apiRequest(`/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate, filters })}`) } export function getPublicDashboardPerformance( @@ -368,8 +370,8 @@ export function getPublicDashboardPerformance( return apiRequest(`/public/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate }, { password, captcha })}`) } -export function getDashboardGoals(siteId: string, startDate?: string, endDate?: string, limit = 10): Promise { - return apiRequest(`/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit })}`) +export function getDashboardGoals(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise { + return apiRequest(`/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit, filters })}`) } export function getPublicDashboardGoals( @@ -378,3 +380,25 @@ export function getPublicDashboardGoals( ): Promise { return apiRequest(`/public/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit }, { password, captcha })}`) } + +// ─── Event Properties ──────────────────────────────────────────────── + +export interface EventPropertyKey { + key: string + count: number +} + +export interface EventPropertyValue { + value: string + count: number +} + +export function getEventPropertyKeys(siteId: string, eventName: string, startDate?: string, endDate?: string): Promise { + return apiRequest<{ keys: EventPropertyKey[] }>(`/sites/${siteId}/goals/${encodeURIComponent(eventName)}/properties${buildQuery({ startDate, endDate })}`) + .then(r => r?.keys || []) +} + +export function getEventPropertyValues(siteId: string, eventName: string, propName: string, startDate?: string, endDate?: string, limit = 20): Promise { + return apiRequest<{ values: EventPropertyValue[] }>(`/sites/${siteId}/goals/${encodeURIComponent(eventName)}/properties/${encodeURIComponent(propName)}${buildQuery({ startDate, endDate, limit })}`) + .then(r => r?.values || []) +} diff --git a/lib/filters.ts b/lib/filters.ts new file mode 100644 index 0000000..e6fbac2 --- /dev/null +++ b/lib/filters.ts @@ -0,0 +1,60 @@ +// * Dimension filter types and utilities for dashboard filtering + +export interface DimensionFilter { + dimension: string + operator: 'is' | 'is_not' | 'contains' | 'not_contains' + values: string[] +} + +export const DIMENSION_LABELS: Record = { + page: 'Page', + referrer: 'Referrer', + country: 'Country', + city: 'City', + region: 'Region', + browser: 'Browser', + os: 'OS', + device: 'Device', + utm_source: 'UTM Source', + utm_medium: 'UTM Medium', + utm_campaign: 'UTM Campaign', +} + +export const OPERATOR_LABELS: Record = { + is: 'is', + is_not: 'is not', + contains: 'contains', + not_contains: 'does not contain', +} + +export const DIMENSIONS = Object.keys(DIMENSION_LABELS) +export const OPERATORS = Object.keys(OPERATOR_LABELS) as DimensionFilter['operator'][] + +/** Serialize filters to query param format: "browser|is|Chrome,country|is|US" */ +export function serializeFilters(filters: DimensionFilter[]): string { + if (!filters.length) return '' + return filters + .map(f => `${f.dimension}|${f.operator}|${f.values.join(';')}`) + .join(',') +} + +/** Parse filters from URL search param string */ +export function parseFiltersFromURL(raw: string): DimensionFilter[] { + if (!raw) return [] + return raw.split(',').map(part => { + const [dimension, operator, valuesRaw] = part.split('|') + return { + dimension, + operator: operator as DimensionFilter['operator'], + values: valuesRaw?.split(';') ?? [], + } + }).filter(f => f.dimension && f.operator && f.values.length > 0) +} + +/** Build display label for a filter pill */ +export function filterLabel(f: DimensionFilter): string { + const dim = DIMENSION_LABELS[f.dimension] || f.dimension + const op = OPERATOR_LABELS[f.operator] || f.operator + const val = f.values.length > 1 ? `${f.values[0]} +${f.values.length - 1}` : f.values[0] + return `${dim} ${op} ${val}` +} diff --git a/lib/swr/dashboard.ts b/lib/swr/dashboard.ts index 6c55300..db2ce84 100644 --- a/lib/swr/dashboard.ts +++ b/lib/swr/dashboard.ts @@ -35,14 +35,14 @@ import type { const fetchers = { site: (siteId: string) => getSite(siteId), dashboard: (siteId: string, start: string, end: string) => getDashboard(siteId, start, end), - dashboardOverview: (siteId: string, start: string, end: string, interval?: string) => getDashboardOverview(siteId, start, end, interval), - dashboardPages: (siteId: string, start: string, end: string) => getDashboardPages(siteId, start, end), - dashboardLocations: (siteId: string, start: string, end: string) => getDashboardLocations(siteId, start, end), - dashboardDevices: (siteId: string, start: string, end: string) => getDashboardDevices(siteId, start, end), - dashboardReferrers: (siteId: string, start: string, end: string) => getDashboardReferrers(siteId, start, end), - dashboardPerformance: (siteId: string, start: string, end: string) => getDashboardPerformance(siteId, start, end), - dashboardGoals: (siteId: string, start: string, end: string) => getDashboardGoals(siteId, start, end), - stats: (siteId: string, start: string, end: string) => getStats(siteId, start, end), + dashboardOverview: (siteId: string, start: string, end: string, interval?: string, filters?: string) => getDashboardOverview(siteId, start, end, interval, filters), + dashboardPages: (siteId: string, start: string, end: string, filters?: string) => getDashboardPages(siteId, start, end, undefined, filters), + dashboardLocations: (siteId: string, start: string, end: string, filters?: string) => getDashboardLocations(siteId, start, end, undefined, undefined, filters), + dashboardDevices: (siteId: string, start: string, end: string, filters?: string) => getDashboardDevices(siteId, start, end, undefined, filters), + dashboardReferrers: (siteId: string, start: string, end: string, filters?: string) => getDashboardReferrers(siteId, start, end, undefined, filters), + dashboardPerformance: (siteId: string, start: string, end: string, filters?: string) => getDashboardPerformance(siteId, start, end, filters), + dashboardGoals: (siteId: string, start: string, end: string, filters?: string) => getDashboardGoals(siteId, start, end, undefined, filters), + stats: (siteId: string, start: string, end: string, filters?: string) => getStats(siteId, start, end, filters), dailyStats: (siteId: string, start: string, end: string, interval: 'hour' | 'day' | 'minute') => getDailyStats(siteId, start, end, interval), realtime: (siteId: string) => getRealtime(siteId), @@ -94,10 +94,10 @@ export function useDashboard(siteId: string, start: string, end: string) { } // * Hook for stats (refreshed less frequently) -export function useStats(siteId: string, start: string, end: string) { +export function useStats(siteId: string, start: string, end: string, filters?: string) { return useSWR( - siteId && start && end ? ['stats', siteId, start, end] : null, - () => fetchers.stats(siteId, start, end), + siteId && start && end ? ['stats', siteId, start, end, filters] : null, + () => fetchers.stats(siteId, start, end, filters), { ...dashboardSWRConfig, // * Refresh every 60 seconds for stats @@ -144,10 +144,10 @@ export function useRealtime(siteId: string, refreshInterval: number = 5000) { } // * Hook for focused dashboard overview data (Fix 4.2: Efficient Data Transfer) -export function useDashboardOverview(siteId: string, start: string, end: string, interval?: string) { +export function useDashboardOverview(siteId: string, start: string, end: string, interval?: string, filters?: string) { return useSWR( - siteId && start && end ? ['dashboardOverview', siteId, start, end, interval] : null, - () => fetchers.dashboardOverview(siteId, start, end, interval), + siteId && start && end ? ['dashboardOverview', siteId, start, end, interval, filters] : null, + () => fetchers.dashboardOverview(siteId, start, end, interval, filters), { ...dashboardSWRConfig, refreshInterval: 60 * 1000, @@ -157,10 +157,10 @@ export function useDashboardOverview(siteId: string, start: string, end: string, } // * Hook for focused dashboard pages data -export function useDashboardPages(siteId: string, start: string, end: string) { +export function useDashboardPages(siteId: string, start: string, end: string, filters?: string) { return useSWR( - siteId && start && end ? ['dashboardPages', siteId, start, end] : null, - () => fetchers.dashboardPages(siteId, start, end), + siteId && start && end ? ['dashboardPages', siteId, start, end, filters] : null, + () => fetchers.dashboardPages(siteId, start, end, filters), { ...dashboardSWRConfig, refreshInterval: 60 * 1000, @@ -170,10 +170,10 @@ export function useDashboardPages(siteId: string, start: string, end: string) { } // * Hook for focused dashboard locations data -export function useDashboardLocations(siteId: string, start: string, end: string) { +export function useDashboardLocations(siteId: string, start: string, end: string, filters?: string) { return useSWR( - siteId && start && end ? ['dashboardLocations', siteId, start, end] : null, - () => fetchers.dashboardLocations(siteId, start, end), + siteId && start && end ? ['dashboardLocations', siteId, start, end, filters] : null, + () => fetchers.dashboardLocations(siteId, start, end, filters), { ...dashboardSWRConfig, refreshInterval: 60 * 1000, @@ -183,10 +183,10 @@ export function useDashboardLocations(siteId: string, start: string, end: string } // * Hook for focused dashboard devices data -export function useDashboardDevices(siteId: string, start: string, end: string) { +export function useDashboardDevices(siteId: string, start: string, end: string, filters?: string) { return useSWR( - siteId && start && end ? ['dashboardDevices', siteId, start, end] : null, - () => fetchers.dashboardDevices(siteId, start, end), + siteId && start && end ? ['dashboardDevices', siteId, start, end, filters] : null, + () => fetchers.dashboardDevices(siteId, start, end, filters), { ...dashboardSWRConfig, refreshInterval: 60 * 1000, @@ -196,10 +196,10 @@ export function useDashboardDevices(siteId: string, start: string, end: string) } // * Hook for focused dashboard referrers data -export function useDashboardReferrers(siteId: string, start: string, end: string) { +export function useDashboardReferrers(siteId: string, start: string, end: string, filters?: string) { return useSWR( - siteId && start && end ? ['dashboardReferrers', siteId, start, end] : null, - () => fetchers.dashboardReferrers(siteId, start, end), + siteId && start && end ? ['dashboardReferrers', siteId, start, end, filters] : null, + () => fetchers.dashboardReferrers(siteId, start, end, filters), { ...dashboardSWRConfig, refreshInterval: 60 * 1000, @@ -209,10 +209,10 @@ export function useDashboardReferrers(siteId: string, start: string, end: string } // * Hook for focused dashboard performance data -export function useDashboardPerformance(siteId: string, start: string, end: string) { +export function useDashboardPerformance(siteId: string, start: string, end: string, filters?: string) { return useSWR( - siteId && start && end ? ['dashboardPerformance', siteId, start, end] : null, - () => fetchers.dashboardPerformance(siteId, start, end), + siteId && start && end ? ['dashboardPerformance', siteId, start, end, filters] : null, + () => fetchers.dashboardPerformance(siteId, start, end, filters), { ...dashboardSWRConfig, refreshInterval: 60 * 1000, @@ -222,10 +222,10 @@ export function useDashboardPerformance(siteId: string, start: string, end: stri } // * Hook for focused dashboard goals data -export function useDashboardGoals(siteId: string, start: string, end: string) { +export function useDashboardGoals(siteId: string, start: string, end: string, filters?: string) { return useSWR( - siteId && start && end ? ['dashboardGoals', siteId, start, end] : null, - () => fetchers.dashboardGoals(siteId, start, end), + siteId && start && end ? ['dashboardGoals', siteId, start, end, filters] : null, + () => fetchers.dashboardGoals(siteId, start, end, filters), { ...dashboardSWRConfig, refreshInterval: 60 * 1000, diff --git a/public/script.js b/public/script.js index ee704b6..8c9dd01 100644 --- a/public/script.js +++ b/public/script.js @@ -322,7 +322,7 @@ var EVENT_NAME_MAX = 64; var EVENT_NAME_REGEX = /^[a-zA-Z0-9_]+$/; - function trackCustomEvent(eventName) { + function trackCustomEvent(eventName, props) { if (typeof eventName !== 'string' || !eventName.trim()) return; var name = eventName.trim().toLowerCase(); if (name.length > EVENT_NAME_MAX || !EVENT_NAME_REGEX.test(name)) { @@ -342,6 +342,20 @@ session_id: getSessionId(), name: name, }; + // * Attach custom properties if provided (max 30 props, key max 200 chars, value max 2000 chars) + if (props && typeof props === 'object' && !Array.isArray(props)) { + var sanitized = {}; + var count = 0; + for (var key in props) { + if (!props.hasOwnProperty(key)) continue; + if (count >= 30) break; + var k = String(key).substring(0, 200); + var v = String(props[key]).substring(0, 2000); + sanitized[k] = v; + count++; + } + if (count > 0) payload.props = sanitized; + } fetch(apiUrl + '/api/v1/events', { method: 'POST', headers: { 'Content-Type': 'application/json' },