From 6f1956b74092709daa6ea728984653db0456d9c6 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 8 Mar 2026 23:40:49 +0100 Subject: [PATCH 001/109] fix: chart no longer shows tomorrow's date on 7/30-day views Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + app/sites/[id]/page.tsx | 10 +++++----- package-lock.json | 8 ++++---- package.json | 2 +- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fee81c..c03a16f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- **Charts no longer show tomorrow's date.** The visitor chart on 7-day and 30-day views could display the next day with zero traffic, making it look like a sudden drop. The chart now ends on today. - **Login no longer gets stuck after updates.** If you happened to have Pulse open when a new version was deployed, logging back in could get stuck on a loading screen. The app now automatically refreshes itself to pick up the latest version. - **City and region data is now accurate.** Location data was incorrectly showing the CDN server's location (e.g. Paris, Villeurbanne) instead of the visitor's actual city. Fixed by reading the correct visitor IP header from Bunny CDN. - **"Reset Data" now clears everything.** Previously, resetting a site's data in Settings only removed pageviews and daily stats. Uptime check history, uptime daily stats, and cached dashboard data were left behind. All collected data is now properly cleared when you reset, while your site configuration, goals, funnels, and uptime monitors are kept. diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index 61809c0..f09b824 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -19,7 +19,7 @@ import { type Stats, type DailyStat, } from '@/lib/api/stats' -import { getDateRange } from '@ciphera-net/ui' +import { getDateRange, formatDate } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui' import { Button } from '@ciphera-net/ui' import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui' @@ -71,7 +71,7 @@ function loadSavedSettings(): { function getInitialDateRange(): { start: string; end: string } { const settings = loadSavedSettings() if (settings?.type === 'today') { - const today = new Date().toISOString().split('T')[0] + const today = formatDate(new Date()) return { start: today, end: today } } if (settings?.type === '7') return getDateRange(7) @@ -377,7 +377,7 @@ export default function SiteDashboardPage() { // Save intervals when they change useEffect(() => { let type = 'custom' - const today = new Date().toISOString().split('T')[0] + const today = formatDate(new Date()) if (dateRange.start === today && dateRange.end === today) type = 'today' else if (dateRange.start === getDateRange(7).start) type = '7' else if (dateRange.start === getDateRange(30).start) type = '30' @@ -458,7 +458,7 @@ export default function SiteDashboardPage() { variant="input" className="min-w-[140px]" value={ - dateRange.start === new Date().toISOString().split('T')[0] && dateRange.end === new Date().toISOString().split('T')[0] + dateRange.start === formatDate(new Date()) && dateRange.end === formatDate(new Date()) ? 'today' : dateRange.start === getDateRange(7).start ? '7' @@ -478,7 +478,7 @@ export default function SiteDashboardPage() { saveSettings('30', range) } else if (value === 'today') { - const today = new Date().toISOString().split('T')[0] + const today = formatDate(new Date()) const range = { start: today, end: today } setDateRange(range) saveSettings('today', range) diff --git a/package-lock.json b/package-lock.json index 0ebe5d2..fe86ed2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.13.0-alpha", "dependencies": { - "@ciphera-net/ui": "^0.0.92", + "@ciphera-net/ui": "^0.0.93", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", "@simplewebauthn/browser": "^13.2.2", @@ -1665,9 +1665,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.0.92", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.92/68088da543459b34ab9fe780713537713b5e0673", - "integrity": "sha512-R+8fyvz7DhqHyJ2gai6ssRY3rE2OQlNt3ZepcpMeZouhujSNLO7KtApS4AP2qvJhKuWTnPNLj/B+kOWC3L4LMA==", + "version": "0.0.93", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.93/f120357e54c0d9365579fd5aeb7fc5a5e18d52d6", + "integrity": "sha512-Fz+zx+qcIvN0CU6LPpj6o9KD9eDu9fg9wTiWU0eNK80qHEJcEqsiaJwY2NufLqTsBc0kDYmMG7iN0MzAI/m5/g==", "dependencies": { "@radix-ui/react-icons": "^1.3.0", "clsx": "^2.1.0", diff --git a/package.json b/package.json index 10e1953..99d6af7 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ciphera-net/ui": "^0.0.92", + "@ciphera-net/ui": "^0.0.93", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", "@simplewebauthn/browser": "^13.2.2", From 397a5afef9ae0cebd5a9499b94368556b54998cc Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 00:07:12 +0100 Subject: [PATCH 002/109] fix: capitalize technology labels in dashboard --- CHANGELOG.md | 1 + components/dashboard/TechSpecs.tsx | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c03a16f..1242fbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed - **Charts no longer show tomorrow's date.** The visitor chart on 7-day and 30-day views could display the next day with zero traffic, making it look like a sudden drop. The chart now ends on today. +- **Capitalized technology labels.** Device types, browsers, and OS names in the Technology panel now display with a capital first letter (e.g. "Desktop" instead of "desktop"). - **Login no longer gets stuck after updates.** If you happened to have Pulse open when a new version was deployed, logging back in could get stuck on a loading screen. The app now automatically refreshes itself to pick up the latest version. - **City and region data is now accurate.** Location data was incorrectly showing the CDN server's location (e.g. Paris, Villeurbanne) instead of the visitor's actual city. Fixed by reading the correct visitor IP header from Bunny CDN. - **"Reset Data" now clears everything.** Previously, resetting a site's data in Settings only removed pageviews and daily stats. Uptime check history, uptime daily stats, and cached dashboard data were left behind. All collected data is now properly cleared when you reset, while your site configuration, goals, funnels, and uptime monitors are kept. diff --git a/components/dashboard/TechSpecs.tsx b/components/dashboard/TechSpecs.tsx index 170cda6..d31c27e 100644 --- a/components/dashboard/TechSpecs.tsx +++ b/components/dashboard/TechSpecs.tsx @@ -25,6 +25,11 @@ interface TechSpecsProps { type Tab = 'browsers' | 'os' | 'devices' | 'screens' +function capitalize(s: string): string { + if (!s) return s + return s.charAt(0).toUpperCase() + s.slice(1) +} + const LIMIT = 7 const TAB_TO_DIMENSION: Record = { browsers: 'browser', os: 'os', devices: 'device' } @@ -162,7 +167,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co >
{item.icon && {item.icon}} - {item.name} + {capitalize(item.name)}
@@ -222,7 +227,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
{item.icon && {item.icon}} - {item.name === 'Unknown' ? 'Unknown' : item.name} + {capitalize(item.name)}
{formatNumber(item.pageviews)} From 7f9ad0e977e303b351cbbdfd4f58195d7b6a32b6 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 00:23:31 +0100 Subject: [PATCH 003/109] refactor: switch icons from react-icons to Phosphor Replace react-icons and @radix-ui/react-icons with @phosphor-icons/react for a consistent icon style across all dashboard panels. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + components/OfflineBanner.tsx | 4 +- components/dashboard/Campaigns.tsx | 4 +- components/dashboard/Locations.tsx | 9 +-- components/dashboard/TechSpecs.tsx | 6 +- components/tools/UtmBuilder.tsx | 4 +- lib/utils/icons.tsx | 126 +++++++++++++---------------- next.config.ts | 2 +- package-lock.json | 25 +++--- package.json | 3 +- 10 files changed, 84 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1242fbd..f303c7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Improved - **Cleaner site navigation.** Dashboard, Uptime, Funnels, and Settings now use an underline tab bar instead of floating buttons. The active section is highlighted with an orange underline, making it easy to see where you are and switch between views. +- **Consistent icon style.** All dashboard icons now use a single, unified icon set for a cleaner look across Technology, Locations, Campaigns, and Referrers panels. ### Fixed diff --git a/components/OfflineBanner.tsx b/components/OfflineBanner.tsx index bca7c20..784b8d9 100644 --- a/components/OfflineBanner.tsx +++ b/components/OfflineBanner.tsx @@ -1,13 +1,13 @@ 'use client'; -import { FiWifiOff } from 'react-icons/fi'; +import { WifiSlash } from '@phosphor-icons/react'; export function OfflineBanner({ isOnline }: { isOnline: boolean }) { if (isOnline) return null; return (
- + You are currently offline. Changes may not be saved.
); diff --git a/components/dashboard/Campaigns.tsx b/components/dashboard/Campaigns.tsx index defc79e..9f0ffe0 100644 --- a/components/dashboard/Campaigns.tsx +++ b/components/dashboard/Campaigns.tsx @@ -9,7 +9,7 @@ import { Modal, ArrowRightIcon } from '@ciphera-net/ui' import { ListSkeleton } from '@/components/skeletons' import { getCampaigns, CampaignStat } from '@/lib/api/stats' import { getReferrerFavicon, getReferrerIcon, getReferrerDisplayName } from '@/lib/utils/icons' -import { FaBullhorn } from 'react-icons/fa' +import { Megaphone } from '@phosphor-icons/react' import UtmBuilder from '@/components/tools/UtmBuilder' import { type DimensionFilter } from '@/lib/filters' @@ -190,7 +190,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp ) : (
- +

Track your marketing campaigns diff --git a/components/dashboard/Locations.tsx b/components/dashboard/Locations.tsx index 5cc15d4..74f819e 100644 --- a/components/dashboard/Locations.tsx +++ b/components/dashboard/Locations.tsx @@ -9,8 +9,7 @@ import iso3166 from 'iso-3166-2' import WorldMap from './WorldMap' import { Modal, GlobeIcon } from '@ciphera-net/ui' import { ListSkeleton } from '@/components/skeletons' -import { SiTorproject } from 'react-icons/si' -import { FaUserSecret, FaSatellite } from 'react-icons/fa' +import { ShieldCheck, Detective, Broadcast } from '@phosphor-icons/react' import { getCountries, getCities, getRegions } from '@/lib/api/stats' import { type DimensionFilter } from '@/lib/filters' @@ -69,11 +68,11 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' switch (countryCode) { case 'T1': - return + return case 'A1': - return + return case 'A2': - return + return case 'O1': case 'EU': case 'AP': diff --git a/components/dashboard/TechSpecs.tsx b/components/dashboard/TechSpecs.tsx index d31c27e..d9251ec 100644 --- a/components/dashboard/TechSpecs.tsx +++ b/components/dashboard/TechSpecs.tsx @@ -5,7 +5,7 @@ import { logger } from '@/lib/utils/logger' import { formatNumber } from '@ciphera-net/ui' import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard' import { getBrowserIcon, getOSIcon, getDeviceIcon } from '@/lib/utils/icons' -import { MdMonitor } from 'react-icons/md' +import { Monitor } from '@phosphor-icons/react' import { Modal, GridIcon } from '@ciphera-net/ui' import { ListSkeleton } from '@/components/skeletons' import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats' @@ -64,7 +64,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co data = res.map(d => ({ name: d.device, pageviews: d.pageviews, icon: getDeviceIcon(d.device) })) } else if (activeTab === 'screens') { const res = await getScreenResolutions(siteId, dateRange.start, dateRange.end, 100) - data = res.map(s => ({ name: s.screen_resolution, pageviews: s.pageviews, icon: })) + data = res.map(s => ({ name: s.screen_resolution, pageviews: s.pageviews, icon: })) } setFullData(filterUnknown(data)) } catch (e) { @@ -88,7 +88,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co case 'devices': return devices.map(d => ({ name: d.device, pageviews: d.pageviews, icon: getDeviceIcon(d.device) })) case 'screens': - return screenResolutions.map(s => ({ name: s.screen_resolution, pageviews: s.pageviews, icon: })) + return screenResolutions.map(s => ({ name: s.screen_resolution, pageviews: s.pageviews, icon: })) default: return [] } diff --git a/components/tools/UtmBuilder.tsx b/components/tools/UtmBuilder.tsx index 7f5ad34..8bced1b 100644 --- a/components/tools/UtmBuilder.tsx +++ b/components/tools/UtmBuilder.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import { logger } from '@/lib/utils/logger' -import { CopyIcon, CheckIcon } from '@radix-ui/react-icons' +import { Copy, Check } from '@phosphor-icons/react' import { listSites, Site } from '@/lib/api/sites' import { Select, Input, Button } from '@ciphera-net/ui' @@ -205,7 +205,7 @@ export default function UtmBuilder({ initialSiteId }: UtmBuilderProps) { className="ml-4 shrink-0 h-9 w-9 p-0 rounded-lg" title="Copy to clipboard" > - {copied ? : } + {copied ? : }

)} diff --git a/lib/utils/icons.tsx b/lib/utils/icons.tsx index f3cca87..2b45fec 100644 --- a/lib/utils/icons.tsx +++ b/lib/utils/icons.tsx @@ -1,98 +1,80 @@ import React from 'react' +import { + Globe, + WindowsLogo, + AppleLogo, + LinuxLogo, + AndroidLogo, + Question, + DeviceMobile, + DeviceTablet, + Desktop, + GoogleLogo, + FacebookLogo, + XLogo, + LinkedinLogo, + InstagramLogo, + GithubLogo, + YoutubeLogo, + RedditLogo, + Robot, +} from '@phosphor-icons/react' /** * Google's public favicon service base URL. * Append `?domain=&sz=` to get a favicon. */ export const FAVICON_SERVICE_URL = 'https://www.google.com/s2/favicons' -import { - FaChrome, - FaFirefox, - FaSafari, - FaEdge, - FaOpera, - FaInternetExplorer, - FaWindows, - FaApple, - FaLinux, - FaAndroid, - FaDesktop, - FaMobileAlt, - FaTabletAlt, - FaGoogle, - FaFacebook, - FaLinkedin, - FaInstagram, - FaGithub, - FaYoutube, - FaReddit, - FaQuestion, - FaGlobe -} from 'react-icons/fa' -import { FaXTwitter } from 'react-icons/fa6' -import { SiBrave, SiOpenai, SiPerplexity, SiAnthropic, SiGooglegemini } from 'react-icons/si' -import { RiRobot2Fill } from 'react-icons/ri' -import { MdDeviceUnknown, MdSmartphone, MdTabletMac, MdDesktopWindows } from 'react-icons/md' export function getBrowserIcon(browserName: string) { - if (!browserName) return - const lower = browserName.toLowerCase() - if (lower.includes('chrome')) return - if (lower.includes('firefox')) return - if (lower.includes('safari')) return - if (lower.includes('edge')) return - if (lower.includes('opera')) return - if (lower.includes('ie') || lower.includes('explorer')) return - if (lower.includes('brave')) return - - return + if (!browserName) return + return } export function getOSIcon(osName: string) { - if (!osName) return + if (!osName) return const lower = osName.toLowerCase() - if (lower.includes('win')) return - if (lower.includes('mac') || lower.includes('ios')) return - if (lower.includes('linux') || lower.includes('ubuntu') || lower.includes('debian')) return - if (lower.includes('android')) return - - return + if (lower.includes('win')) return + if (lower.includes('mac') || lower.includes('ios')) return + if (lower.includes('linux') || lower.includes('ubuntu') || lower.includes('debian')) return + if (lower.includes('android')) return + + return } export function getDeviceIcon(deviceName: string) { - if (!deviceName) return + if (!deviceName) return const lower = deviceName.toLowerCase() - if (lower.includes('mobile') || lower.includes('phone')) return - if (lower.includes('tablet') || lower.includes('ipad')) return - if (lower.includes('desktop') || lower.includes('laptop')) return - - return + if (lower.includes('mobile') || lower.includes('phone')) return + if (lower.includes('tablet') || lower.includes('ipad')) return + if (lower.includes('desktop') || lower.includes('laptop')) return + + return } export function getReferrerIcon(referrerName: string) { - if (!referrerName) return + if (!referrerName) return const lower = referrerName.toLowerCase() - if (lower.includes('google')) return - if (lower.includes('facebook')) return - if (lower.includes('twitter') || lower.includes('t.co') || lower.includes('x.com')) return - if (lower.includes('linkedin')) return - if (lower.includes('instagram')) return - if (lower.includes('github')) return - if (lower.includes('youtube')) return - if (lower.includes('reddit')) return + if (lower.includes('google')) return + if (lower.includes('facebook')) return + if (lower.includes('twitter') || lower.includes('t.co') || lower.includes('x.com')) return + if (lower.includes('linkedin')) return + if (lower.includes('instagram')) return + if (lower.includes('github')) return + if (lower.includes('youtube')) return + if (lower.includes('reddit')) return // AI assistants and search tools - if (lower.includes('chatgpt') || lower.includes('openai')) return - if (lower.includes('perplexity')) return - if (lower.includes('claude') || lower.includes('anthropic')) return - if (lower.includes('gemini')) return - if (lower.includes('copilot')) return - if (lower.includes('deepseek')) return - if (lower.includes('grok') || lower.includes('x.ai')) return - if (lower.includes('phind')) return - if (lower.includes('you.com')) return + if (lower.includes('chatgpt') || lower.includes('openai')) return + if (lower.includes('perplexity')) return + if (lower.includes('claude') || lower.includes('anthropic')) return + if (lower.includes('gemini')) return + if (lower.includes('copilot')) return + if (lower.includes('deepseek')) return + if (lower.includes('grok') || lower.includes('x.ai')) return + if (lower.includes('phind')) return + if (lower.includes('you.com')) return - // Try to use a generic globe or maybe check if it is a URL - return + return } const REFERRER_NO_FAVICON = ['direct', 'unknown', ''] diff --git a/next.config.ts b/next.config.ts index 0052619..05f96a5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -30,7 +30,7 @@ const nextConfig: NextConfig = { // * Privacy-first: Disable analytics and telemetry productionBrowserSourceMaps: false, experimental: { - optimizePackageImports: ['react-icons'], + optimizePackageImports: ['@phosphor-icons/react'], }, images: { remotePatterns: [ diff --git a/package-lock.json b/package-lock.json index fe86ed2..7c842cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@ciphera-net/ui": "^0.0.93", "@ducanh2912/next-pwa": "^10.2.9", - "@radix-ui/react-icons": "^1.3.0", + "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", @@ -26,7 +26,6 @@ "next": "^16.1.1", "react": "^19.2.3", "react-dom": "^19.2.3", - "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-simple-maps": "^3.0.0", "recharts": "^2.15.0", @@ -3274,6 +3273,19 @@ "node": ">=12.4.0" } }, + "node_modules/@phosphor-icons/react": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", + "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">= 16.8", + "react-dom": ">= 16.8" + } + }, "node_modules/@radix-ui/react-icons": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", @@ -11081,15 +11093,6 @@ "react": "^19.2.4" } }, - "node_modules/react-icons": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", - "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", - "license": "MIT", - "peerDependencies": { - "react": "*" - } - }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index 99d6af7..6cd541d 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "dependencies": { "@ciphera-net/ui": "^0.0.93", "@ducanh2912/next-pwa": "^10.2.9", - "@radix-ui/react-icons": "^1.3.0", + "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", @@ -30,7 +30,6 @@ "next": "^16.1.1", "react": "^19.2.3", "react-dom": "^19.2.3", - "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-simple-maps": "^3.0.0", "recharts": "^2.15.0", From b6199e8a3ac42d512b0857a12a992cf36ad2cc6f Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 01:39:40 +0100 Subject: [PATCH 004/109] chore: bump @ciphera-net/ui to ^0.0.94 Picks up the Phosphor icon migration in ciphera-ui. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6cd541d..f4c5e13 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ciphera-net/ui": "^0.0.93", + "@ciphera-net/ui": "^0.0.94", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", From f516c59d32ba2d90edbf2b77041c4935a40d45ad Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 01:43:24 +0100 Subject: [PATCH 005/109] chore: regenerate package-lock.json for @ciphera-net/ui 0.0.94 Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7c842cf..29f9e05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.13.0-alpha", "dependencies": { - "@ciphera-net/ui": "^0.0.93", + "@ciphera-net/ui": "^0.0.94", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", @@ -1664,11 +1664,11 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.0.93", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.93/f120357e54c0d9365579fd5aeb7fc5a5e18d52d6", - "integrity": "sha512-Fz+zx+qcIvN0CU6LPpj6o9KD9eDu9fg9wTiWU0eNK80qHEJcEqsiaJwY2NufLqTsBc0kDYmMG7iN0MzAI/m5/g==", + "version": "0.0.94", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.94/e1fd8da171da4fb65c2ea1756793f94a3762178b", + "integrity": "sha512-8xsgLdiCrRgohySlSTcL2RNKA0IYCTsdyYYMY2qleCaHJly3480kEQGfAmckwmNvkOPoaDBuh3C72iFkyfQssw==", "dependencies": { - "@radix-ui/react-icons": "^1.3.0", + "@phosphor-icons/react": "^2.1.10", "clsx": "^2.1.0", "framer-motion": "^12.0.0", "sonner": "^2.0.7", @@ -3286,15 +3286,6 @@ "react-dom": ">= 16.8" } }, - "node_modules/@radix-ui/react-icons": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", - "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==", - "license": "MIT", - "peerDependencies": { - "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", From 7ff5be7c4e2487f2c40322d4a1e73e636d495f89 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 02:00:32 +0100 Subject: [PATCH 006/109] fix: pre-render Phosphor icons in capabilities array The /features page mixed inline SVG JSX with Phosphor component references in the capabilities array. The typeof check couldn't distinguish them, causing a prerender crash: "Objects are not valid as a React child (found: object with keys {$$typeof, render, displayName})". Pre-rendering Share2Icon and GlobeIcon as JSX makes all entries consistent and eliminates the conditional entirely. --- app/features/page.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/features/page.tsx b/app/features/page.tsx index c985bb6..8431d58 100644 --- a/app/features/page.tsx +++ b/app/features/page.tsx @@ -83,12 +83,12 @@ const capabilities = [ description: 'Automatically parse UTM parameters. Built-in link builder for campaigns, sources, and mediums.', }, { - icon: Share2Icon, + icon: , title: 'Shared Dashboards', description: 'Generate a public link to share analytics with clients or teammates — no login required.', }, { - icon: GlobeIcon, + icon: , title: 'Geographic Insights', description: 'Country, region, and city-level breakdowns. IPs are never stored — derived at request time only.', }, @@ -190,7 +190,7 @@ export default function FeaturesPage() { className="flex gap-4" >
- {typeof cap.icon === 'object' ? cap.icon : } + {cap.icon}

From a05e2e94b835d1b8d2d4efcddc348ad5b2fe3451 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 02:26:15 +0100 Subject: [PATCH 007/109] feat: add hide unknown locations toggle in site settings Adds toggle in Data & Privacy > Filtering section to exclude entries where geographic data could not be resolved from location stats. Co-Authored-By: Claude Opus 4.6 --- app/sites/[id]/settings/page.tsx | 28 ++++++++++++++++++++++++++++ lib/api/sites.ts | 4 ++++ 2 files changed, 32 insertions(+) diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index fefa324..2d6e11e 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -73,6 +73,8 @@ export default function SiteSettingsPage() { enable_performance_insights: false, // Bot and noise filtering filter_bots: true, + // Hide unknown locations + hide_unknown_locations: false, // Data retention (6 = free-tier max; safe default) data_retention_months: 6 }) @@ -146,6 +148,8 @@ export default function SiteSettingsPage() { enable_performance_insights: data.enable_performance_insights ?? false, // Bot and noise filtering (default to true) filter_bots: data.filter_bots ?? true, + // Hide unknown locations (default to false) + hide_unknown_locations: data.hide_unknown_locations ?? false, // Data retention (default 6 = free-tier max; avoids flash-then-clamp for existing sites) data_retention_months: data.data_retention_months ?? 6 }) @@ -161,6 +165,7 @@ export default function SiteSettingsPage() { collect_screen_resolution: data.collect_screen_resolution ?? true, enable_performance_insights: data.enable_performance_insights ?? false, filter_bots: data.filter_bots ?? true, + hide_unknown_locations: data.hide_unknown_locations ?? false, data_retention_months: data.data_retention_months ?? 6 }) if (data.has_password) { @@ -277,6 +282,8 @@ export default function SiteSettingsPage() { enable_performance_insights: formData.enable_performance_insights, // Bot and noise filtering filter_bots: formData.filter_bots, + // Hide unknown locations + hide_unknown_locations: formData.hide_unknown_locations, // Data retention data_retention_months: formData.data_retention_months }) @@ -293,6 +300,7 @@ export default function SiteSettingsPage() { collect_screen_resolution: formData.collect_screen_resolution, enable_performance_insights: formData.enable_performance_insights, filter_bots: formData.filter_bots, + hide_unknown_locations: formData.hide_unknown_locations, data_retention_months: formData.data_retention_months }) loadSite() @@ -360,6 +368,7 @@ export default function SiteSettingsPage() { collect_screen_resolution: formData.collect_screen_resolution, enable_performance_insights: formData.enable_performance_insights, filter_bots: formData.filter_bots, + hide_unknown_locations: formData.hide_unknown_locations, data_retention_months: formData.data_retention_months }) !== initialFormRef.current @@ -885,6 +894,25 @@ export default function SiteSettingsPage() {

+
+
+
+

Hide unknown locations

+

+ Exclude entries where geographic data could not be resolved from location stats +

+
+ +
+
{/* Performance Insights Toggle */} diff --git a/lib/api/sites.ts b/lib/api/sites.ts index 75157cf..e17d886 100644 --- a/lib/api/sites.ts +++ b/lib/api/sites.ts @@ -21,6 +21,8 @@ export interface Site { enable_performance_insights?: boolean // Bot and noise filtering filter_bots?: boolean + // Hide unknown locations from stats + hide_unknown_locations?: boolean // Data retention (months); 0 = keep forever data_retention_months?: number created_at: string @@ -49,6 +51,8 @@ export interface UpdateSiteRequest { enable_performance_insights?: boolean // Bot and noise filtering filter_bots?: boolean + // Hide unknown locations from stats + hide_unknown_locations?: boolean // Data retention (months); 0 = keep forever data_retention_months?: number } From 3002c4f58c1a2618a54d3fb18f3186f491eb6998 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 02:37:28 +0100 Subject: [PATCH 008/109] docs: add hide unknown locations to changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f303c7f..57de9de 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 + +- **Hide unknown locations.** New toggle in Site Settings under Data & Privacy to hide "Unknown" entries from your Locations panel. When geographic data can't be determined for a visitor, it normally shows as "Unknown" in countries, cities, and regions. Turn this on to keep your location stats clean and only show resolved locations. + ### Improved - **Cleaner site navigation.** Dashboard, Uptime, Funnels, and Settings now use an underline tab bar instead of floating buttons. The active section is highlighted with an orange underline, making it easy to see where you are and switch between views. From 4d99334bcfb6970d04c0eb8f0a00424f961e6a48 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 03:44:05 +0100 Subject: [PATCH 009/109] feat: add chart annotations Inline annotation markers on the dashboard chart with create/edit/delete UI. Color-coded categories: deploy, campaign, incident, other. --- CHANGELOG.md | 1 + app/sites/[id]/page.tsx | 27 ++++ components/dashboard/Chart.tsx | 221 ++++++++++++++++++++++++++++++++- lib/api/annotations.ts | 55 ++++++++ lib/swr/dashboard.ts | 16 +++ 5 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 lib/api/annotations.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 57de9de..8c16875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added - **Hide unknown locations.** New toggle in Site Settings under Data & Privacy to hide "Unknown" entries from your Locations panel. When geographic data can't be determined for a visitor, it normally shows as "Unknown" in countries, cities, and regions. Turn this on to keep your location stats clean and only show resolved locations. +- **Chart annotations.** Mark events on your dashboard timeline — like deploys, campaigns, or incidents — so you always know why traffic changed. Click the + button on the chart to add a note on any date. Annotations appear as colored markers on the chart: blue for deploys, green for campaigns, red for incidents. Hover to see the details. Team owners and admins can add, edit, and delete annotations; everyone else (including public dashboard viewers) can see them. ### Improved diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index f09b824..df8dde6 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -51,7 +51,9 @@ import { useStats, useDailyStats, useCampaigns, + useAnnotations, } from '@/lib/swr/dashboard' +import { createAnnotation, updateAnnotation, deleteAnnotation, type AnnotationCategory } from '@/lib/api/annotations' function loadSavedSettings(): { type?: string @@ -233,6 +235,26 @@ export default function SiteDashboardPage() { const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end) const { data: prevDailyStats } = useDailyStats(siteId, prevRange.start, prevRange.end, interval) const { data: campaigns } = useCampaigns(siteId, dateRange.start, dateRange.end) + const { data: annotations, mutate: mutateAnnotations } = useAnnotations(siteId, dateRange.start, dateRange.end) + + // Annotation mutation handlers + const handleCreateAnnotation = async (data: { date: string; text: string; category: string }) => { + await createAnnotation(siteId, { ...data, category: data.category as AnnotationCategory }) + mutateAnnotations() + toast.success('Annotation added') + } + + const handleUpdateAnnotation = async (id: string, data: { date: string; text: string; category: string }) => { + await updateAnnotation(siteId, id, { ...data, category: data.category as AnnotationCategory }) + mutateAnnotations() + toast.success('Annotation updated') + } + + const handleDeleteAnnotation = async (id: string) => { + await deleteAnnotation(siteId, id) + mutateAnnotations() + toast.success('Annotation deleted') + } // Derive typed values from SWR data const site = overview?.site ?? null @@ -521,6 +543,11 @@ export default function SiteDashboardPage() { multiDayInterval={multiDayInterval} setMultiDayInterval={setMultiDayInterval} lastUpdatedAt={lastUpdatedAt} + annotations={annotations} + canManageAnnotations={true} + onCreateAnnotation={handleCreateAnnotation} + onUpdateAnnotation={handleUpdateAnnotation} + onDeleteAnnotation={handleDeleteAnnotation} />
diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index 9c3701f..1ee7c8f 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -10,10 +10,11 @@ import { CartesianGrid, Tooltip, ResponsiveContainer, + ReferenceLine, } from 'recharts' import type { TooltipProps } from 'recharts' import { formatNumber, formatDuration, formatUpdatedAgo } from '@ciphera-net/ui' -import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, DownloadIcon } from '@ciphera-net/ui' +import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, DownloadIcon, PlusIcon } from '@ciphera-net/ui' import { Checkbox } from '@ciphera-net/ui' const COLORS = { @@ -42,6 +43,27 @@ const CHART_COLORS_DARK = { tooltipBorder: 'var(--color-neutral-700)', } +const ANNOTATION_COLORS: Record = { + deploy: '#3b82f6', + campaign: '#22c55e', + incident: '#ef4444', + other: '#a3a3a3', +} + +const ANNOTATION_LABELS: Record = { + deploy: 'Deploy', + campaign: 'Campaign', + incident: 'Incident', + other: 'Note', +} + +interface AnnotationData { + id: string + date: string + text: string + category: string +} + export interface DailyStat { date: string pageviews: number @@ -70,6 +92,11 @@ interface ChartProps { setMultiDayInterval: (interval: 'hour' | 'day') => void onExportChart?: () => void lastUpdatedAt?: number | null + annotations?: AnnotationData[] + canManageAnnotations?: boolean + onCreateAnnotation?: (data: { date: string; text: string; category: string }) => Promise + onUpdateAnnotation?: (id: string, data: { date: string; text: string; category: string }) => Promise + onDeleteAnnotation?: (id: string) => Promise } type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration' @@ -208,6 +235,11 @@ export default function Chart({ setMultiDayInterval, onExportChart, lastUpdatedAt, + annotations, + canManageAnnotations, + onCreateAnnotation, + onUpdateAnnotation, + onDeleteAnnotation, }: ChartProps) { const [metric, setMetric] = useState('visitors') const [showComparison, setShowComparison] = useState(false) @@ -269,6 +301,31 @@ export default function Chart({ } }) + const annotationMarkers = useMemo(() => { + if (!annotations?.length) return [] + const byDate = new Map() + for (const a of annotations) { + const existing = byDate.get(a.date) || [] + existing.push(a) + byDate.set(a.date, existing) + } + const markers: { x: string; annotations: AnnotationData[] }[] = [] + for (const [date, anns] of byDate) { + const match = chartData.find((d) => { + const orig = d.originalDate + return orig.startsWith(date) || orig === date + }) + if (match) { + markers.push({ x: match.date, annotations: anns }) + } + } + return markers + }, [annotations, chartData]) + + const [annotationForm, setAnnotationForm] = useState<{ visible: boolean; editingId?: string; date: string; text: string; category: string }>({ + visible: false, date: new Date().toISOString().slice(0, 10), text: '', category: 'other' + }) + // ─── Metrics ─────────────────────────────────────────────────────── const calculateTrend = (current: number, previous?: number) => { @@ -427,6 +484,16 @@ export default function Chart({ > + + {canManageAnnotations && ( + + )} @@ -535,12 +602,74 @@ export default function Chart({ animationDuration={400} animationEasing="ease-out" /> + + {annotationMarkers.map((marker) => { + const primaryCategory = marker.annotations[0].category + const color = ANNOTATION_COLORS[primaryCategory] || ANNOTATION_COLORS.other + return ( + + ) + })} )} + {annotationMarkers.length > 0 && ( +
+ Annotations: + {annotationMarkers.map((marker) => { + const primary = marker.annotations[0] + const color = ANNOTATION_COLORS[primary.category] || ANNOTATION_COLORS.other + const count = marker.annotations.length + return ( + + ) + })} +
+ )} + {/* Live indicator */} {lastUpdatedAt != null && (
@@ -553,6 +682,96 @@ export default function Chart({
)} + + {annotationForm.visible && ( +
+
+

+ {annotationForm.editingId ? 'Edit annotation' : 'Add annotation'} +

+
+
+ + setAnnotationForm((f) => ({ ...f, date: e.target.value }))} + className="w-full px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30" + /> +
+
+ + setAnnotationForm((f) => ({ ...f, text: e.target.value.slice(0, 200) }))} + placeholder="e.g. Launched new homepage" + maxLength={200} + className="w-full px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30" + autoFocus + /> + {annotationForm.text.length}/200 +
+
+ + +
+
+
+
+ {annotationForm.editingId && ( + + )} +
+
+ + +
+
+
+
+ )} ) } diff --git a/lib/api/annotations.ts b/lib/api/annotations.ts new file mode 100644 index 0000000..6f44a12 --- /dev/null +++ b/lib/api/annotations.ts @@ -0,0 +1,55 @@ +import apiRequest from './client' + +export type AnnotationCategory = 'deploy' | 'campaign' | 'incident' | 'other' + +export interface Annotation { + id: string + site_id: string + date: string + text: string + category: AnnotationCategory + created_by: string + created_at: string + updated_at: string +} + +export interface CreateAnnotationRequest { + date: string + text: string + category?: AnnotationCategory +} + +export interface UpdateAnnotationRequest { + date: string + text: string + category: AnnotationCategory +} + +export async function listAnnotations(siteId: string, startDate?: string, endDate?: string): Promise { + const params = new URLSearchParams() + if (startDate) params.set('start_date', startDate) + if (endDate) params.set('end_date', endDate) + const qs = params.toString() + const res = await apiRequest<{ annotations: Annotation[] }>(`/sites/${siteId}/annotations${qs ? `?${qs}` : ''}`) + return res?.annotations ?? [] +} + +export async function createAnnotation(siteId: string, data: CreateAnnotationRequest): Promise { + return apiRequest(`/sites/${siteId}/annotations`, { + method: 'POST', + body: JSON.stringify(data), + }) +} + +export async function updateAnnotation(siteId: string, annotationId: string, data: UpdateAnnotationRequest): Promise { + return apiRequest(`/sites/${siteId}/annotations/${annotationId}`, { + method: 'PUT', + body: JSON.stringify(data), + }) +} + +export async function deleteAnnotation(siteId: string, annotationId: string): Promise { + await apiRequest(`/sites/${siteId}/annotations/${annotationId}`, { + method: 'DELETE', + }) +} diff --git a/lib/swr/dashboard.ts b/lib/swr/dashboard.ts index db2ce84..1911bb8 100644 --- a/lib/swr/dashboard.ts +++ b/lib/swr/dashboard.ts @@ -16,6 +16,8 @@ import { getStats, getDailyStats, } from '@/lib/api/stats' +import { listAnnotations } from '@/lib/api/annotations' +import type { Annotation } from '@/lib/api/annotations' import { getSite } from '@/lib/api/sites' import type { Site } from '@/lib/api/sites' import type { @@ -48,6 +50,7 @@ const fetchers = { realtime: (siteId: string) => getRealtime(siteId), campaigns: (siteId: string, start: string, end: string, limit: number) => getCampaigns(siteId, start, end, limit), + annotations: (siteId: string, start: string, end: string) => listAnnotations(siteId, start, end), } // * Standard SWR config for dashboard data @@ -247,5 +250,18 @@ export function useCampaigns(siteId: string, start: string, end: string, limit = ) } +// * Hook for annotations data +export function useAnnotations(siteId: string, startDate: string, endDate: string) { + return useSWR( + siteId && startDate && endDate ? ['annotations', siteId, startDate, endDate] : null, + () => fetchers.annotations(siteId, startDate, endDate), + { + ...dashboardSWRConfig, + refreshInterval: 60 * 1000, + dedupingInterval: 10 * 1000, + } + ) +} + // * Re-export for convenience export { fetchers } From 5fc6f183db1c91e9663f6a5194f82c12e4742721 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 04:17:58 +0100 Subject: [PATCH 010/109] feat: annotation UX improvements - Custom calendar (DatePicker) instead of native date input - Custom dropdown (Select) instead of native select - EU date format (DD/MM/YYYY) in tooltips and form - Right-click context menu on chart to add annotations - Optional time field (HH:MM) for precise timestamps - Escape key to dismiss, loading state on save - Bump @ciphera-net/ui to 0.0.95 --- app/sites/[id]/page.tsx | 4 +- components/dashboard/Chart.tsx | 238 ++++++++++++++++++++++++++------- lib/api/annotations.ts | 3 + package-lock.json | 8 +- package.json | 2 +- 5 files changed, 201 insertions(+), 54 deletions(-) diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index df8dde6..cf3398e 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -238,13 +238,13 @@ export default function SiteDashboardPage() { const { data: annotations, mutate: mutateAnnotations } = useAnnotations(siteId, dateRange.start, dateRange.end) // Annotation mutation handlers - const handleCreateAnnotation = async (data: { date: string; text: string; category: string }) => { + const handleCreateAnnotation = async (data: { date: string; time?: string; text: string; category: string }) => { await createAnnotation(siteId, { ...data, category: data.category as AnnotationCategory }) mutateAnnotations() toast.success('Annotation added') } - const handleUpdateAnnotation = async (id: string, data: { date: string; text: string; category: string }) => { + const handleUpdateAnnotation = async (id: string, data: { date: string; time?: string; text: string; category: string }) => { await updateAnnotation(siteId, id, { ...data, category: data.category as AnnotationCategory }) mutateAnnotations() toast.success('Annotation updated') diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index 1ee7c8f..3df1850 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useMemo, useRef, useCallback } from 'react' +import { useState, useMemo, useRef, useCallback, useEffect } from 'react' import { useTheme } from '@ciphera-net/ui' import { AreaChart, @@ -13,8 +13,8 @@ import { ReferenceLine, } from 'recharts' import type { TooltipProps } from 'recharts' -import { formatNumber, formatDuration, formatUpdatedAgo } from '@ciphera-net/ui' -import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, DownloadIcon, PlusIcon } from '@ciphera-net/ui' +import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui' +import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui' import { Checkbox } from '@ciphera-net/ui' const COLORS = { @@ -57,9 +57,17 @@ const ANNOTATION_LABELS: Record = { other: 'Note', } +const CATEGORY_OPTIONS = [ + { value: 'deploy', label: 'Deploy' }, + { value: 'campaign', label: 'Campaign' }, + { value: 'incident', label: 'Incident' }, + { value: 'other', label: 'Other' }, +] + interface AnnotationData { id: string date: string + time?: string | null text: string category: string } @@ -94,8 +102,8 @@ interface ChartProps { lastUpdatedAt?: number | null annotations?: AnnotationData[] canManageAnnotations?: boolean - onCreateAnnotation?: (data: { date: string; text: string; category: string }) => Promise - onUpdateAnnotation?: (id: string, data: { date: string; text: string; category: string }) => Promise + onCreateAnnotation?: (data: { date: string; time?: string; text: string; category: string }) => Promise + onUpdateAnnotation?: (id: string, data: { date: string; time?: string; text: string; category: string }) => Promise onDeleteAnnotation?: (id: string) => Promise } @@ -103,6 +111,11 @@ type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration' // ─── Helpers ───────────────────────────────────────────────────────── +function formatEU(dateStr: string): string { + const [y, m, d] = dateStr.split('-') + return `${d}/${m}/${y}` +} + function formatAxisValue(value: number): string { if (value >= 1e6) return `${+(value / 1e6).toFixed(1)}M` if (value >= 1000) return `${+(value / 1000).toFixed(1)}k` @@ -246,12 +259,32 @@ export default function Chart({ const chartContainerRef = useRef(null) const { resolvedTheme } = useTheme() + // ─── Annotation state ───────────────────────────────────────────── + const [annotationForm, setAnnotationForm] = useState<{ + visible: boolean; editingId?: string; date: string; time: string; text: string; category: string + }>({ visible: false, date: new Date().toISOString().slice(0, 10), time: '', text: '', category: 'other' }) + const [calendarOpen, setCalendarOpen] = useState(false) + const [saving, setSaving] = useState(false) + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; date: string } | null>(null) + + // Close context menu and annotation form on Escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (calendarOpen) { setCalendarOpen(false); return } + if (contextMenu) { setContextMenu(null); return } + if (annotationForm.visible) { setAnnotationForm(f => ({ ...f, visible: false })); return } + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [calendarOpen, contextMenu, annotationForm.visible]) + const handleExportChart = useCallback(async () => { if (onExportChart) { onExportChart(); return } if (!chartContainerRef.current) return try { const { toPng } = await import('html-to-image') - // Resolve the actual background color from the DOM (CSS vars don't work in html-to-image) const bg = getComputedStyle(chartContainerRef.current).backgroundColor || (resolvedTheme === 'dark' ? '#171717' : '#ffffff') const dataUrl = await toPng(chartContainerRef.current, { cacheBust: true, @@ -322,9 +355,55 @@ export default function Chart({ return markers }, [annotations, chartData]) - const [annotationForm, setAnnotationForm] = useState<{ visible: boolean; editingId?: string; date: string; text: string; category: string }>({ - visible: false, date: new Date().toISOString().slice(0, 10), text: '', category: 'other' - }) + // ─── Right-click handler ────────────────────────────────────────── + const handleChartContextMenu = useCallback((e: React.MouseEvent) => { + if (!canManageAnnotations) return + e.preventDefault() + const rect = e.currentTarget.getBoundingClientRect() + const relX = e.clientX - rect.left + const leftMargin = 48 + const rightMargin = 16 + const plotWidth = rect.width - leftMargin - rightMargin + const fraction = Math.max(0, Math.min(1, (relX - leftMargin) / plotWidth)) + const index = Math.min(Math.round(fraction * (chartData.length - 1)), chartData.length - 1) + const point = chartData[index] + if (point) { + setContextMenu({ x: e.clientX, y: e.clientY, date: point.originalDate.slice(0, 10) }) + } + }, [canManageAnnotations, chartData]) + + // ─── Annotation form handlers ───────────────────────────────────── + const handleSaveAnnotation = useCallback(async () => { + if (saving) return + const payload = { + date: annotationForm.date, + time: annotationForm.time || undefined, + text: annotationForm.text.trim(), + category: annotationForm.category, + } + setSaving(true) + try { + if (annotationForm.editingId && onUpdateAnnotation) { + await onUpdateAnnotation(annotationForm.editingId, payload) + } else if (onCreateAnnotation) { + await onCreateAnnotation(payload) + } + setAnnotationForm({ visible: false, date: '', time: '', text: '', category: 'other' }) + } finally { + setSaving(false) + } + }, [annotationForm, saving, onCreateAnnotation, onUpdateAnnotation]) + + const handleDeleteAnnotation = useCallback(async () => { + if (!annotationForm.editingId || !onDeleteAnnotation) return + setSaving(true) + try { + await onDeleteAnnotation(annotationForm.editingId) + setAnnotationForm({ visible: false, date: '', time: '', text: '', category: 'other' }) + } finally { + setSaving(false) + } + }, [annotationForm.editingId, onDeleteAnnotation]) // ─── Metrics ─────────────────────────────────────────────────────── @@ -350,7 +429,6 @@ export default function Chart({ const hasData = data.length > 0 const hasAnyNonZero = hasData && chartData.some((d) => (d[metric] as number) > 0) - // Count metrics should never show decimal Y-axis ticks const isCountMetric = metric === 'visitors' || metric === 'pageviews' // ─── X-Axis Ticks ───────────────────────────────────────────────── @@ -487,7 +565,7 @@ export default function Chart({ {canManageAnnotations && ( + + + + )} + + {/* ─── Annotation Form Modal ─────────────────────────────────── */} {annotationForm.visible && (
@@ -690,15 +804,45 @@ export default function Chart({ {annotationForm.editingId ? 'Edit annotation' : 'Add annotation'}
+ {/* Date picker trigger */}
- setAnnotationForm((f) => ({ ...f, date: e.target.value }))} - className="w-full px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30" - /> +
+ {/* Time input */} +
+ +
+ setAnnotationForm((f) => ({ ...f, time: e.target.value }))} + className="flex-1 px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30" + /> + {annotationForm.time && ( + + )} +
+
+ {/* Note */}
{annotationForm.text.length}/200
+ {/* Category - custom Select */}
- + onChange={(v) => setAnnotationForm((f) => ({ ...f, category: v }))} + options={CATEGORY_OPTIONS} + variant="input" + fullWidth + align="left" + />
@@ -731,13 +874,9 @@ export default function Chart({ {annotationForm.editingId && ( @@ -746,32 +885,37 @@ export default function Chart({
)} + + {/* ─── DatePicker overlay (single mode) ─────────────────────── */} + setCalendarOpen(false)} + onApply={() => {}} + initialRange={{ start: annotationForm.date || new Date().toISOString().slice(0, 10), end: annotationForm.date || new Date().toISOString().slice(0, 10) }} + mode="single" + onSelect={(date) => { + setAnnotationForm((f) => ({ ...f, date })) + setCalendarOpen(false) + }} + /> ) } diff --git a/lib/api/annotations.ts b/lib/api/annotations.ts index 6f44a12..7b88fe4 100644 --- a/lib/api/annotations.ts +++ b/lib/api/annotations.ts @@ -6,6 +6,7 @@ export interface Annotation { id: string site_id: string date: string + time?: string | null text: string category: AnnotationCategory created_by: string @@ -15,12 +16,14 @@ export interface Annotation { export interface CreateAnnotationRequest { date: string + time?: string text: string category?: AnnotationCategory } export interface UpdateAnnotationRequest { date: string + time?: string text: string category: AnnotationCategory } diff --git a/package-lock.json b/package-lock.json index 29f9e05..8eca35a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.13.0-alpha", "dependencies": { - "@ciphera-net/ui": "^0.0.94", + "@ciphera-net/ui": "^0.0.95", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", @@ -1664,9 +1664,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.0.94", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.94/e1fd8da171da4fb65c2ea1756793f94a3762178b", - "integrity": "sha512-8xsgLdiCrRgohySlSTcL2RNKA0IYCTsdyYYMY2qleCaHJly3480kEQGfAmckwmNvkOPoaDBuh3C72iFkyfQssw==", + "version": "0.0.95", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.95/ddb41cb4513d4727f38e34f1a8a8d49a7fc9600d", + "integrity": "sha512-Bo0RLetcuPIwU7g5u6oNs9eftlD5Tb82356Ht/wQamx/6egUTZWnouXEEoOlKVhSPRACisys/AGnUQU6JPM+cA==", "dependencies": { "@phosphor-icons/react": "^2.1.10", "clsx": "^2.1.0", diff --git a/package.json b/package.json index f4c5e13..3030cb1 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ciphera-net/ui": "^0.0.94", + "@ciphera-net/ui": "^0.0.95", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", From 86c11dc16f9db3ec948844d60cd8ff01c513f02f Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 13:02:06 +0100 Subject: [PATCH 011/109] chore: bump @ciphera-net/ui to ^0.1.0 ShadCN-quality restyle of shared UI components. --- package-lock.json | 21 +++++++++++++++++---- package.json | 2 +- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8eca35a..f9ef254 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.13.0-alpha", "dependencies": { - "@ciphera-net/ui": "^0.0.95", + "@ciphera-net/ui": "^0.1.0", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", @@ -1664,11 +1664,12 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.0.95", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.95/ddb41cb4513d4727f38e34f1a8a8d49a7fc9600d", - "integrity": "sha512-Bo0RLetcuPIwU7g5u6oNs9eftlD5Tb82356Ht/wQamx/6egUTZWnouXEEoOlKVhSPRACisys/AGnUQU6JPM+cA==", + "version": "0.1.0", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.1.0/433d68844bdbbef83b38ede046b0b601a03a065f", + "integrity": "sha512-uDdHg8KHuaiTaPqxP/0uALnH1UOc0Oz8Z2FnlCGwL7ma2keUotDd/3ooYWIBtFMazeq7Qrq7VM0YrvHd8puqug==", "dependencies": { "@phosphor-icons/react": "^2.1.10", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.0", "framer-motion": "^12.0.0", "sonner": "^2.0.7", @@ -6046,6 +6047,18 @@ "node": ">=6.0" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", diff --git a/package.json b/package.json index 3030cb1..0d1bcbf 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ciphera-net/ui": "^0.0.95", + "@ciphera-net/ui": "^0.1.0", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", From 3f81cb0e48f294ca5dd58ceec10862a9b30e970c Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 13:24:29 +0100 Subject: [PATCH 012/109] feat: adopt ShadCN chart primitives Add ChartContainer, ChartConfig, ChartTooltip, ChartTooltipContent primitives ported from ShadCN's chart pattern. Refactor all 3 chart locations (dashboard, funnels, uptime) to use CSS variable-driven theming instead of duplicated CHART_COLORS_LIGHT/DARK objects. - Add --chart-1 through --chart-5, --chart-grid, --chart-axis CSS vars - Remove duplicated color objects from 3 files (-223 lines) - Add accessibilityLayer to all charts - Rounded bar corners on funnel chart - Tooltips use Tailwind dark classes instead of imperative style props Co-Authored-By: Claude Opus 4.6 --- app/sites/[id]/funnels/[funnelId]/page.tsx | 140 ++++----- app/sites/[id]/uptime/page.tsx | 137 ++++----- components/charts/chart.tsx | 325 +++++++++++++++++++++ components/charts/index.ts | 8 + components/dashboard/Chart.tsx | 91 +++--- styles/globals.css | 19 ++ 6 files changed, 497 insertions(+), 223 deletions(-) create mode 100644 components/charts/chart.tsx create mode 100644 components/charts/index.ts diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx index 2dfd50d..1026cec 100644 --- a/app/sites/[id]/funnels/[funnelId]/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -1,10 +1,10 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import { ApiError } from '@/lib/api/client' import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels' -import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, useTheme, Button } from '@ciphera-net/ui' +import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui' import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons' import Link from 'next/link' import { @@ -13,27 +13,17 @@ import { XAxis, YAxis, CartesianGrid, - Tooltip, - ResponsiveContainer, Cell } from 'recharts' +import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/charts' import { getDateRange } from '@ciphera-net/ui' -const CHART_COLORS_LIGHT = { - border: 'var(--color-neutral-200)', - axis: 'var(--color-neutral-400)', - tooltipBg: '#ffffff', - tooltipBorder: 'var(--color-neutral-200)', -} - -const CHART_COLORS_DARK = { - border: 'var(--color-neutral-700)', - axis: 'var(--color-neutral-500)', - tooltipBg: 'var(--color-neutral-800)', - tooltipBorder: 'var(--color-neutral-700)', -} - -const BRAND_ORANGE = 'var(--color-brand-orange)' +const chartConfig = { + visitors: { + label: 'Visitors', + color: 'var(--chart-1)', + }, +} satisfies ChartConfig export default function FunnelReportPage() { const params = useParams() @@ -74,12 +64,6 @@ export default function FunnelReportPage() { loadData() }, [loadData]) - const { resolvedTheme } = useTheme() - const chartColors = useMemo( - () => (resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT), - [resolvedTheme] - ) - const handleDelete = async () => { if (!confirm('Are you sure you want to delete this funnel?')) return @@ -204,64 +188,56 @@ export default function FunnelReportPage() {

Funnel Visualization

-
- - - - - - { - if (active && payload && payload.length) { - const data = payload[0].payload; - return ( -
-

{label}

-

- {data.visitors.toLocaleString()} visitors + + + + + + { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +

+

{label}

+

+ {data.visitors.toLocaleString()} visitors +

+ {data.dropoff > 0 && ( +

+ {Math.round(data.dropoff)}% drop-off

- {data.dropoff > 0 && ( -

- {Math.round(data.dropoff)}% drop-off -

- )} - {data.conversion > 0 && ( -

- {Math.round(data.conversion)}% conversion (overall) -

- )} -
- ); - } - return null; - }} - /> - - {chartData.map((entry, index) => ( - - ))} - - - -
+ )} + {data.conversion > 0 && ( +

+ {Math.round(data.conversion)}% conversion (overall) +

+ )} +
+ ); + } + return null; + }} + /> + + {chartData.map((entry, index) => ( + + ))} + + + {/* Detailed Stats Table */} diff --git a/app/sites/[id]/uptime/page.tsx b/app/sites/[id]/uptime/page.tsx index 09ac33c..cfb9876 100644 --- a/app/sites/[id]/uptime/page.tsx +++ b/app/sites/[id]/uptime/page.tsx @@ -29,28 +29,15 @@ import { XAxis, YAxis, CartesianGrid, - Tooltip as RechartsTooltip, - ResponsiveContainer, } from 'recharts' -import type { TooltipProps } from 'recharts' +import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/charts' -// * Chart theme colors (consistent with main Pulse chart) -const CHART_COLORS_LIGHT = { - border: 'var(--color-neutral-200)', - text: 'var(--color-neutral-900)', - textMuted: 'var(--color-neutral-500)', - axis: 'var(--color-neutral-400)', - tooltipBg: '#ffffff', - tooltipBorder: 'var(--color-neutral-200)', -} -const CHART_COLORS_DARK = { - border: 'var(--color-neutral-700)', - text: 'var(--color-neutral-50)', - textMuted: 'var(--color-neutral-400)', - axis: 'var(--color-neutral-500)', - tooltipBg: 'var(--color-neutral-800)', - tooltipBorder: 'var(--color-neutral-700)', -} +const responseTimeChartConfig = { + ms: { + label: 'Response Time', + color: 'var(--chart-1)', + }, +} satisfies ChartConfig // * Status color mapping function getStatusColor(status: string): string { @@ -285,9 +272,6 @@ function UptimeStatusBar({ // * Component: Response time chart (Recharts area chart) function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) { - const { resolvedTheme } = useTheme() - const colors = resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT - // * Prepare data in chronological order (oldest first) const data = [...checks] .reverse() @@ -303,71 +287,58 @@ function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) { if (data.length < 2) return null - const CustomTooltip = ({ active, payload, label }: TooltipProps) => { - if (!active || !payload?.length) return null - return ( -
-
{label}
-
- {payload[0].value}ms -
-
- ) - } - return (

Response Time

-
- - - - - - - - - - - `${v}ms`} - /> - } /> - - - -
+ + + + + + + + + + + `${v}ms`} + /> + {value}ms} + /> + } + /> + + +
) } diff --git a/components/charts/chart.tsx b/components/charts/chart.tsx new file mode 100644 index 0000000..a9c5398 --- /dev/null +++ b/components/charts/chart.tsx @@ -0,0 +1,325 @@ +'use client' + +import * as React from 'react' +import { Tooltip, Legend, ResponsiveContainer } from 'recharts' +import { cn } from '@ciphera-net/ui' + +// ─── ChartConfig ──────────────────────────────────────────────────── + +export type ChartConfig = Record< + string, + { + label?: React.ReactNode + icon?: React.ComponentType + color?: string + theme?: { light: string; dark: string } + } +> + +// ─── ChartContext ─────────────────────────────────────────────────── + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + if (!context) { + throw new Error('useChart must be used within a ') + } + return context +} + +// ─── ChartContainer ──────────────────────────────────────────────── + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & { + config: ChartConfig + children: React.ComponentProps['children'] + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, '')}` + + // Build CSS variables from config + const colorVars = React.useMemo(() => { + const vars: Record = {} + for (const [key, value] of Object.entries(config)) { + if (value.color) { + vars[`--color-${key}`] = value.color + } + } + return vars + }, [config]) + + return ( + +
+ + {children} + +
+
+ ) +}) +ChartContainer.displayName = 'ChartContainer' + +// ─── ChartTooltip ────────────────────────────────────────────────── + +const ChartTooltip = Tooltip + +// ─── ChartTooltipContent ─────────────────────────────────────────── + +const ChartTooltipContent = React.forwardRef< + HTMLDivElement, + React.ComponentProps & + React.ComponentProps<'div'> & { + hideLabel?: boolean + hideIndicator?: boolean + indicator?: 'line' | 'dot' | 'dashed' + nameKey?: string + labelKey?: string + labelFormatter?: (value: string, payload: Record[]) => React.ReactNode + } +>( + ( + { + active, + payload, + className, + indicator = 'dot', + hideLabel = false, + hideIndicator = false, + label, + labelFormatter, + labelKey, + nameKey, + }, + ref, + ) => { + const { config } = useChart() + + const tooltipLabel = React.useMemo(() => { + if (hideLabel || !payload?.length) return null + + const item = payload[0] + const key = `${labelKey || item?.dataKey || item?.name || 'value'}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + const value = + !labelKey && typeof label === 'string' + ? config[label as keyof typeof config]?.label || label + : itemConfig?.label + + if (labelFormatter) { + return labelFormatter( + value as string, + payload as Record[], + ) + } + + return value + }, [label, labelFormatter, payload, hideLabel, config, labelKey]) + + if (!active || !payload?.length) return null + + const nestLabel = payload.length === 1 && indicator !== 'dot' + + return ( +
+ {!nestLabel ? tooltipLabel ? ( +
+ {tooltipLabel} +
+ ) : null : null} +
+ {payload.map((item, index) => { + const key = `${nameKey || item.name || item.dataKey || 'value'}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + const indicatorColor = item.fill || item.color + + return ( +
svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground', + indicator === 'dot' && 'items-center', + )} + > + {itemConfig?.icon ? ( + + ) : ( + !hideIndicator && ( +
+ ) + )} +
+
+ {nestLabel ? tooltipLabel : null} + + {itemConfig?.label || item.name} + +
+ {item.value != null && ( + + {typeof item.value === 'number' + ? item.value.toLocaleString() + : item.value} + + )} +
+
+ ) + })} +
+
+ ) + }, +) +ChartTooltipContent.displayName = 'ChartTooltipContent' + +// ─── ChartLegend ─────────────────────────────────────────────────── + +const ChartLegend = Legend + +const ChartLegendContent = React.forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & + Pick, 'payload' | 'verticalAlign'> & { + hideIcon?: boolean + nameKey?: string + } +>( + ( + { className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey }, + ref, + ) => { + const { config } = useChart() + + if (!payload?.length) return null + + return ( +
+ {payload.map((item) => { + const key = `${nameKey || item.dataKey || 'value'}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + + return ( +
+ {itemConfig?.icon && !hideIcon ? ( + + ) : ( +
+ )} + + {itemConfig?.label} + +
+ ) + })} +
+ ) + }, +) +ChartLegendContent.displayName = 'ChartLegendContent' + +// ─── Helpers ─────────────────────────────────────────────────────── + +function getPayloadConfigFromPayload( + config: ChartConfig, + payload: unknown, + key: string, +) { + if (typeof payload !== 'object' || payload === null) return undefined + + const payloadPayload = + 'payload' in payload && + typeof (payload as Record).payload === 'object' && + (payload as Record).payload !== null + ? ((payload as Record).payload as Record) + : undefined + + let configLabelKey = key + + if ( + key in config + ) { + configLabelKey = key + } else if (payloadPayload) { + const payloadKey = Object.keys(payloadPayload).find( + (k) => payloadPayload[k] === key && k in config, + ) + if (payloadKey) configLabelKey = payloadKey + } + + return configLabelKey in config ? config[configLabelKey] : config[key] +} + +export { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + ChartContext, + useChart, +} diff --git a/components/charts/index.ts b/components/charts/index.ts new file mode 100644 index 0000000..cb253fe --- /dev/null +++ b/components/charts/index.ts @@ -0,0 +1,8 @@ +export { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + type ChartConfig, +} from './chart' diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index 3df1850..6c60c7d 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -8,40 +8,29 @@ import { XAxis, YAxis, CartesianGrid, - Tooltip, - ResponsiveContainer, ReferenceLine, } from 'recharts' -import type { TooltipProps } from 'recharts' +import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/charts' import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui' import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui' import { Checkbox } from '@ciphera-net/ui' const COLORS = { - brand: 'var(--color-brand-orange)', + brand: 'var(--chart-1)', success: 'var(--color-success)', danger: 'var(--color-error)', } -const CHART_COLORS_LIGHT = { - border: 'var(--color-neutral-200)', - grid: 'var(--color-neutral-100)', - text: 'var(--color-neutral-900)', - textMuted: 'var(--color-neutral-500)', - axis: 'var(--color-neutral-400)', - tooltipBg: '#ffffff', - tooltipBorder: 'var(--color-neutral-200)', -} - -const CHART_COLORS_DARK = { - border: 'var(--color-neutral-700)', - grid: 'var(--color-neutral-800)', - text: 'var(--color-neutral-50)', - textMuted: 'var(--color-neutral-400)', - axis: 'var(--color-neutral-500)', - tooltipBg: 'var(--color-neutral-800)', - tooltipBorder: 'var(--color-neutral-700)', -} +const dashboardChartConfig = { + visitors: { label: 'Visitors', color: 'var(--chart-1)' }, + pageviews: { label: 'Pageviews', color: 'var(--chart-1)' }, + bounce_rate: { label: 'Bounce Rate', color: 'var(--chart-1)' }, + avg_duration: { label: 'Visit Duration', color: 'var(--chart-1)' }, + prevVisitors: { label: 'Previous', color: 'var(--chart-axis)' }, + prevPageviews: { label: 'Previous', color: 'var(--chart-axis)' }, + prevBounceRate: { label: 'Previous', color: 'var(--chart-axis)' }, + prevAvgDuration: { label: 'Previous', color: 'var(--chart-axis)' }, +} satisfies ChartConfig const ANNOTATION_COLORS: Record = { deploy: '#3b82f6', @@ -160,7 +149,7 @@ function getTrendContext(dateRange: { start: string; end: string }): string { // ─── Tooltip ───────────────────────────────────────────────────────── -function ChartTooltip({ +function DashboardTooltipContent({ active, payload, label, @@ -169,7 +158,6 @@ function ChartTooltip({ formatNumberFn, showComparison, prevPeriodLabel, - colors, }: { active?: boolean payload?: Array<{ payload: Record; value: number; dataKey?: string }> @@ -179,7 +167,6 @@ function ChartTooltip({ formatNumberFn: (n: number) => string showComparison: boolean prevPeriodLabel?: string - colors: typeof CHART_COLORS_LIGHT }) { if (!active || !payload?.length || !label) return null @@ -199,29 +186,26 @@ function ChartTooltip({ } return ( -
-
+
+
{label}
- + {formatValue(value)} - + {metricLabel}
{hasPrev && ( -
+
vs {formatValue(prev)} {prevPeriodLabel ? `(${prevPeriodLabel})` : ''} {delta !== null && ( 0 ? (metric === 'bounce_rate' ? COLORS.danger : COLORS.success) : delta < 0 ? (metric === 'bounce_rate' ? COLORS.success : COLORS.danger) : colors.textMuted, + color: delta > 0 ? (metric === 'bounce_rate' ? COLORS.danger : COLORS.success) : delta < 0 ? (metric === 'bounce_rate' ? COLORS.success : COLORS.danger) : undefined, }} > {delta > 0 ? '+' : ''}{delta}% @@ -297,11 +281,6 @@ export default function Chart({ } catch { /* noop */ } }, [onExportChart, dateRange, resolvedTheme]) - const colors = useMemo( - () => (resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT), - [resolvedTheme] - ) - // ─── Data ────────────────────────────────────────────────────────── const chartData = data.map((item, i) => { @@ -515,7 +494,7 @@ export default function Chart({ Current - + Previous{prevPeriodLabel ? ` (${prevPeriodLabel})` : ''}
@@ -587,8 +566,8 @@ export default function Chart({
) : (
- - + + @@ -598,11 +577,12 @@ export default function Chart({ - ) => ( - ; value: number; dataKey?: string }>} - label={p.label as string} + - )} - cursor={{ stroke: colors.axis, strokeOpacity: 0.3, strokeWidth: 1 }} + } + cursor={{ stroke: 'var(--chart-axis)', strokeOpacity: 0.3, strokeWidth: 1 }} /> - {hasPrev && ( - +
)}
diff --git a/styles/globals.css b/styles/globals.css index 12feb64..0cfd53e 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -8,6 +8,25 @@ --color-success: #10B981; --color-warning: #F59E0B; --color-error: #EF4444; + + /* * Chart colors */ + --chart-1: #FD5E0F; + --chart-2: #3b82f6; + --chart-3: #22c55e; + --chart-4: #a855f7; + --chart-5: #f59e0b; + --chart-grid: #f5f5f5; + --chart-axis: #a3a3a3; + } + + .dark { + --chart-1: #FD5E0F; + --chart-2: #60a5fa; + --chart-3: #4ade80; + --chart-4: #c084fc; + --chart-5: #fbbf24; + --chart-grid: #262626; + --chart-axis: #737373; } body { From ad747b17720dd840957b0bc076e35eba19b664bc Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 13:31:07 +0100 Subject: [PATCH 013/109] Switch main dashboard chart from AreaChart to BarChart ShadCN-style bar chart with rounded corners, solid fill, clean grid, and translucent comparison bars. Co-Authored-By: Claude Opus 4.6 --- components/dashboard/Chart.tsx | 49 +++++++++------------------------- 1 file changed, 12 insertions(+), 37 deletions(-) diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index 6c60c7d..5b376a2 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -3,8 +3,8 @@ import { useState, useMemo, useRef, useCallback, useEffect } from 'react' import { useTheme } from '@ciphera-net/ui' import { - AreaChart, - Area, + BarChart, + Bar, XAxis, YAxis, CartesianGrid, @@ -567,15 +567,8 @@ export default function Chart({ ) : (
- - - - - - - + } - cursor={{ stroke: 'var(--chart-axis)', strokeOpacity: 0.3, strokeWidth: 1 }} + cursor={false} /> {hasPrev && ( - )} - ) })} - +
)} From 56225bb1ad30e2679ed061060038396e468f21dc Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 13:37:00 +0100 Subject: [PATCH 014/109] Match ShadCN interactive bar chart style - Remove cursor={false} to enable hover highlight - Remove rounded bar corners for flat ShadCN look - Remove explicit stroke/color on grid and axes (inherit from CSS) - Use var(--color-{metric}) for bar fill - Reduce chart height to 250px (ShadCN default) - Simplify bar props to match ShadCN minimal pattern Co-Authored-By: Claude Opus 4.6 --- components/dashboard/Chart.tsx | 42 ++++++++++------------------------ 1 file changed, 12 insertions(+), 30 deletions(-) diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index 5b376a2..ec0b805 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -494,7 +494,7 @@ export default function Chart({ Current - + Previous{prevPeriodLabel ? ` (${prevPeriodLabel})` : ''}
@@ -555,40 +555,31 @@ export default function Chart({
{!hasData ? ( -
+

No data for this period

) : !hasAnyNonZero ? ( -
+

No {metricLabel.toLowerCase()} recorded

) : ( -
- - - +
+ + + { @@ -607,28 +598,19 @@ export default function Chart({ prevPeriodLabel={prevPeriodLabel} /> } - cursor={false} /> {hasPrev && ( )} {annotationMarkers.map((marker) => { From 0dfd0ccb3c12af217f4ee24d286e7ae415755a8c Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 13:45:13 +0100 Subject: [PATCH 015/109] Fix ChartContainer CSS to work without ShadCN theme, match ShadCN bar chart exactly - ChartContainer: replace stroke-border/fill-muted (ShadCN tokens we don't have) with var(--chart-grid) so recharts CSS overrides actually work - Dashboard chart: remove YAxis (ShadCN interactive bar has none) - Remove unused formatAxisValue, formatAxisDuration, tick calculations - Exact ShadCN structure: CartesianGrid vertical={false}, XAxis with tickMargin/minTickGap, single Bar with fill var(--color-{metric}) Co-Authored-By: Claude Opus 4.6 --- components/charts/chart.tsx | 10 +++--- components/dashboard/Chart.tsx | 60 +++++----------------------------- 2 files changed, 12 insertions(+), 58 deletions(-) diff --git a/components/charts/chart.tsx b/components/charts/chart.tsx index a9c5398..a4249f5 100644 --- a/components/charts/chart.tsx +++ b/components/charts/chart.tsx @@ -61,12 +61,10 @@ const ChartContainer = React.forwardRef< data-chart={chartId} ref={ref} className={cn( - '[&_.recharts-cartesian-grid_line[stroke=\'#ccc\']]:stroke-border/50', - '[&_.recharts-curve.recharts-tooltip-cursor]:stroke-border', - '[&_.recharts-polar-grid_[stroke=\'#ccc\']]:stroke-border', - '[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted', - '[&_.recharts-reference-line_[stroke=\'#ccc\']]:stroke-border', - '[&_.recharts-sector[stroke=\'#fff\']]:stroke-transparent', + "[&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-[var(--chart-grid)]", + "[&_.recharts-curve.recharts-tooltip-cursor]:stroke-[var(--chart-grid)]", + "[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-[var(--chart-grid)]", + "[&_.recharts-reference-line_[stroke='#ccc']]:stroke-[var(--chart-grid)]", '[&_.recharts-sector]:outline-none', '[&_.recharts-surface]:outline-none', className, diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index ec0b805..f6169f2 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -6,7 +6,6 @@ import { BarChart, Bar, XAxis, - YAxis, CartesianGrid, ReferenceLine, } from 'recharts' @@ -105,20 +104,6 @@ function formatEU(dateStr: string): string { return `${d}/${m}/${y}` } -function formatAxisValue(value: number): string { - if (value >= 1e6) return `${+(value / 1e6).toFixed(1)}M` - if (value >= 1000) return `${+(value / 1000).toFixed(1)}k` - if (!Number.isInteger(value)) return value.toFixed(1) - return String(value) -} - -function formatAxisDuration(seconds: number): string { - if (!seconds) return '0s' - const m = Math.floor(seconds / 60) - const s = Math.floor(seconds % 60) - if (m > 0) return s > 0 ? `${m}m${s}s` : `${m}m` - return `${s}s` -} function getPrevDateRangeLabel(dateRange: { start: string; end: string }): string { const startDate = new Date(dateRange.start) @@ -408,21 +393,6 @@ export default function Chart({ const hasData = data.length > 0 const hasAnyNonZero = hasData && chartData.some((d) => (d[metric] as number) > 0) - const isCountMetric = metric === 'visitors' || metric === 'pageviews' - - // ─── X-Axis Ticks ───────────────────────────────────────────────── - - const midnightTicks = interval === 'hour' - ? (() => { - const t = chartData - .filter((_, i) => { const d = new Date(data[i].date); return d.getHours() === 0 && d.getMinutes() === 0 }) - .map((c) => c.date) - return t.length > 0 ? t : undefined - })() - : undefined - - const dayTicks = interval === 'day' && chartData.length > 0 ? chartData.map((c) => c.date) : undefined - // ─── Trend Badge ────────────────────────────────────────────────── function TrendBadge({ trend, invert }: { trend: number | null; invert: boolean }) { @@ -565,9 +535,13 @@ export default function Chart({

No {metricLabel.toLowerCase()} recorded

) : ( -
- - +
+ + - { - if (metric === 'bounce_rate') return `${val}%` - if (metric === 'avg_duration') return formatAxisDuration(val) - return formatAxisValue(val) - }} - /> } /> - {hasPrev && ( )} - - - + {annotationMarkers.map((marker) => { const primaryCategory = marker.annotations[0].category const color = ANNOTATION_COLORS[primaryCategory] || ANNOTATION_COLORS.other From 874ff61a466c77ade09d1ca17157553409a559bc Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 13:58:30 +0100 Subject: [PATCH 016/109] Bump @ciphera-net/ui to ^0.1.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0d1bcbf..cffe2d8 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ciphera-net/ui": "^0.1.0", + "@ciphera-net/ui": "^0.1.1", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", From cbf48318ce194d17cb95f33f012e455f4d5d4304 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 13:59:07 +0100 Subject: [PATCH 017/109] Update package-lock.json for @ciphera-net/ui ^0.1.1 --- package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index f9ef254..482959d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.13.0-alpha", "dependencies": { - "@ciphera-net/ui": "^0.1.0", + "@ciphera-net/ui": "^0.1.1", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", @@ -1664,9 +1664,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.1.0", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.1.0/433d68844bdbbef83b38ede046b0b601a03a065f", - "integrity": "sha512-uDdHg8KHuaiTaPqxP/0uALnH1UOc0Oz8Z2FnlCGwL7ma2keUotDd/3ooYWIBtFMazeq7Qrq7VM0YrvHd8puqug==", + "version": "0.1.1", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.1.1/03044e883ed2973c6664e19ff43d2969f1429bd4", + "integrity": "sha512-BM14HMUIVIiyj6YRQFeT07BP56dmHYXtMrSODMrNJ4l5+i6iAw3tbRJ9W5s+5JC1fxn6TbKN43XjTnMfTMHj2w==", "dependencies": { "@phosphor-icons/react": "^2.1.10", "class-variance-authority": "^0.7.1", From 6ccc26ab48e5c9b5657877c2ced58c3a5c41649a Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 14:17:35 +0100 Subject: [PATCH 018/109] Replace WorldMap with Magic UI DottedMap for visitor locations - New DottedMap component using svg-dotted-map with country centroid markers - Marker size scales by pageview proportion (brand orange) - Static country-centroids.ts lookup (~200 ISO codes) - Remove react-simple-maps, i18n-iso-countries, world-atlas CDN dependency --- components/dashboard/DottedMap.tsx | 95 ++++++++++++ components/dashboard/Locations.tsx | 4 +- components/dashboard/WorldMap.tsx | 110 ------------- lib/country-centroids.ts | 205 ++++++++++++++++++++++++ package-lock.json | 240 +---------------------------- package.json | 4 +- 6 files changed, 309 insertions(+), 349 deletions(-) create mode 100644 components/dashboard/DottedMap.tsx delete mode 100644 components/dashboard/WorldMap.tsx create mode 100644 lib/country-centroids.ts diff --git a/components/dashboard/DottedMap.tsx b/components/dashboard/DottedMap.tsx new file mode 100644 index 0000000..dee2e67 --- /dev/null +++ b/components/dashboard/DottedMap.tsx @@ -0,0 +1,95 @@ +'use client' + +import { useMemo } from 'react' +import { createMap } from 'svg-dotted-map' +import { cn } from '@ciphera-net/ui' +import { countryCentroids } from '@/lib/country-centroids' + +interface DottedMapProps { + data: Array<{ country: string; pageviews: number }> + className?: string +} + +export default function DottedMap({ data, className }: DottedMapProps) { + const width = 150 + const height = 75 + const dotRadius = 0.2 + + const { points, addMarkers } = createMap({ width, height, mapSamples: 5000 }) + + const markers = useMemo(() => { + if (!data.length) return [] + + const max = Math.max(...data.map((d) => d.pageviews)) + if (max === 0) return [] + + return data + .filter((d) => d.country && d.country !== 'Unknown' && countryCentroids[d.country]) + .map((d) => ({ + lat: countryCentroids[d.country].lat, + lng: countryCentroids[d.country].lng, + size: 0.4 + (d.pageviews / max) * 0.8, + })) + }, [data]) + + const processedMarkers = addMarkers(markers) + + // Compute stagger helpers + const { xStep, yToRowIndex } = useMemo(() => { + const sorted = [...points].sort((a, b) => a.y - b.y || a.x - b.x) + const rowMap = new Map() + let step = 0 + let prevY = Number.NaN + let prevXInRow = Number.NaN + + for (const p of sorted) { + if (p.y !== prevY) { + prevY = p.y + prevXInRow = Number.NaN + if (!rowMap.has(p.y)) rowMap.set(p.y, rowMap.size) + } + if (!Number.isNaN(prevXInRow)) { + const delta = p.x - prevXInRow + if (delta > 0) step = step === 0 ? delta : Math.min(step, delta) + } + prevXInRow = p.x + } + + return { xStep: step || 1, yToRowIndex: rowMap } + }, [points]) + + return ( + + {points.map((point, index) => { + const rowIndex = yToRowIndex.get(point.y) ?? 0 + const offsetX = rowIndex % 2 === 1 ? xStep / 2 : 0 + return ( + + ) + })} + {processedMarkers.map((marker, index) => { + const rowIndex = yToRowIndex.get(marker.y) ?? 0 + const offsetX = rowIndex % 2 === 1 ? xStep / 2 : 0 + return ( + + ) + })} + + ) +} diff --git a/components/dashboard/Locations.tsx b/components/dashboard/Locations.tsx index 74f819e..faef66b 100644 --- a/components/dashboard/Locations.tsx +++ b/components/dashboard/Locations.tsx @@ -6,7 +6,7 @@ import { formatNumber } from '@ciphera-net/ui' import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard' import * as Flags from 'country-flag-icons/react/3x2' import iso3166 from 'iso-3166-2' -import WorldMap from './WorldMap' +import DottedMap from './DottedMap' import { Modal, GlobeIcon } from '@ciphera-net/ui' import { ListSkeleton } from '@/components/skeletons' import { ShieldCheck, Detective, Broadcast } from '@phosphor-icons/react' @@ -225,7 +225,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '

{getDisabledMessage()}

) : activeTab === 'map' ? ( - hasData ? : ( + hasData ? : (
diff --git a/components/dashboard/WorldMap.tsx b/components/dashboard/WorldMap.tsx deleted file mode 100644 index 6602d51..0000000 --- a/components/dashboard/WorldMap.tsx +++ /dev/null @@ -1,110 +0,0 @@ -'use client' - -import React, { memo, useMemo, useState } from 'react' -import { ComposableMap, Geographies, Geography } from 'react-simple-maps' -import countries from 'i18n-iso-countries' -import enLocale from 'i18n-iso-countries/langs/en.json' -import { useTheme } from '@ciphera-net/ui' - -countries.registerLocale(enLocale) - -const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json" - -interface WorldMapProps { - data: Array<{ country: string; pageviews: number }> -} - -const WorldMap = ({ data }: WorldMapProps) => { - const { resolvedTheme } = useTheme() - const [tooltipContent, setTooltipContent] = useState<{ content: string; x: number; y: number } | null>(null) - - const processedData = useMemo(() => { - const map = new Map() - let max = 0 - data.forEach(item => { - if (item.country === 'Unknown') return - // API returns 2 letter code. Convert to numeric (3 digits string) - const numericCode = countries.alpha2ToNumeric(item.country) - if (numericCode) { - map.set(numericCode, item.pageviews) - if (item.pageviews > max) max = item.pageviews - } - }) - return { map, max } - }, [data]) - - // Plausible-like colors based on provided SVG snippet - const isDark = resolvedTheme === 'dark' - const defaultFill = isDark ? "var(--color-neutral-800)" : "var(--color-neutral-100)" - const defaultStroke = isDark ? "var(--color-neutral-900)" : "#ffffff" - const brandOrange = "var(--color-brand-orange)" - - return ( -
- - - {({ geographies }) => - geographies - .filter(geo => geo.id !== "010") // Remove Antarctica - .map((geo) => { - const id = String(geo.id).padStart(3, '0') - const count = processedData.map.get(id) || 0 - const fillColor = count > 0 ? brandOrange : defaultFill - - return ( - { - const { name } = geo.properties - setTooltipContent({ - content: `${name}: ${count} visitors`, - x: evt.clientX, - y: evt.clientY - }) - }} - onMouseLeave={() => { - setTooltipContent(null) - }} - onMouseMove={(evt) => { - setTooltipContent(prev => prev ? { ...prev, x: evt.clientX, y: evt.clientY } : null) - }} - /> - ) - }) - } - - - {tooltipContent && ( -
- {tooltipContent.content} -
- )} -
- ) -} - -export default memo(WorldMap) diff --git a/lib/country-centroids.ts b/lib/country-centroids.ts new file mode 100644 index 0000000..79f73ee --- /dev/null +++ b/lib/country-centroids.ts @@ -0,0 +1,205 @@ +/** + * Country centroids: ISO 3166-1 alpha-2 → { lat, lng } + * Used to place markers on the DottedMap for visitor locations. + */ +export const countryCentroids: Record = { + AD: { lat: 42.5, lng: 1.5 }, + AE: { lat: 24.0, lng: 54.0 }, + AF: { lat: 33.0, lng: 65.0 }, + AG: { lat: 17.1, lng: -61.8 }, + AL: { lat: 41.0, lng: 20.0 }, + AM: { lat: 40.0, lng: 45.0 }, + AO: { lat: -12.5, lng: 18.5 }, + AR: { lat: -34.0, lng: -64.0 }, + AT: { lat: 47.3, lng: 13.3 }, + AU: { lat: -25.0, lng: 134.0 }, + AZ: { lat: 40.5, lng: 47.5 }, + BA: { lat: 44.0, lng: 17.8 }, + BB: { lat: 13.2, lng: -59.5 }, + BD: { lat: 24.0, lng: 90.0 }, + BE: { lat: 50.8, lng: 4.0 }, + BF: { lat: 13.0, lng: -1.5 }, + BG: { lat: 43.0, lng: 25.0 }, + BH: { lat: 26.0, lng: 50.6 }, + BI: { lat: -3.5, lng: 29.9 }, + BJ: { lat: 9.3, lng: 2.3 }, + BN: { lat: 4.5, lng: 114.7 }, + BO: { lat: -17.0, lng: -65.0 }, + BR: { lat: -10.0, lng: -55.0 }, + BS: { lat: 24.3, lng: -76.0 }, + BT: { lat: 27.5, lng: 90.5 }, + BW: { lat: -22.0, lng: 24.0 }, + BY: { lat: 53.0, lng: 28.0 }, + BZ: { lat: 17.3, lng: -88.8 }, + CA: { lat: 56.0, lng: -96.0 }, + CD: { lat: -3.0, lng: 23.0 }, + CF: { lat: 7.0, lng: 21.0 }, + CG: { lat: -1.0, lng: 15.0 }, + CH: { lat: 47.0, lng: 8.0 }, + CI: { lat: 8.0, lng: -5.5 }, + CL: { lat: -30.0, lng: -71.0 }, + CM: { lat: 6.0, lng: 12.5 }, + CN: { lat: 35.0, lng: 105.0 }, + CO: { lat: 4.0, lng: -72.0 }, + CR: { lat: 10.0, lng: -84.0 }, + CU: { lat: 22.0, lng: -79.5 }, + CV: { lat: 16.0, lng: -24.0 }, + CY: { lat: 35.0, lng: 33.0 }, + CZ: { lat: 49.8, lng: 15.5 }, + DE: { lat: 51.2, lng: 10.4 }, + DJ: { lat: 11.5, lng: 43.1 }, + DK: { lat: 56.0, lng: 10.0 }, + DM: { lat: 15.4, lng: -61.4 }, + DO: { lat: 19.0, lng: -70.7 }, + DZ: { lat: 28.0, lng: 3.0 }, + EC: { lat: -2.0, lng: -77.5 }, + EE: { lat: 59.0, lng: 26.0 }, + EG: { lat: 27.0, lng: 30.0 }, + ER: { lat: 15.0, lng: 39.0 }, + ES: { lat: 40.0, lng: -4.0 }, + ET: { lat: 8.0, lng: 38.0 }, + FI: { lat: 64.0, lng: 26.0 }, + FJ: { lat: -18.0, lng: 175.0 }, + FM: { lat: 6.9, lng: 158.2 }, + FR: { lat: 46.0, lng: 2.0 }, + GA: { lat: -1.0, lng: 11.8 }, + GB: { lat: 54.0, lng: -2.0 }, + GD: { lat: 12.1, lng: -61.7 }, + GE: { lat: 42.0, lng: 43.5 }, + GH: { lat: 8.0, lng: -2.0 }, + GM: { lat: 13.5, lng: -15.3 }, + GN: { lat: 11.0, lng: -10.0 }, + GQ: { lat: 2.0, lng: 10.0 }, + GR: { lat: 39.0, lng: 22.0 }, + GT: { lat: 15.5, lng: -90.3 }, + GW: { lat: 12.0, lng: -15.0 }, + GY: { lat: 5.0, lng: -59.0 }, + HK: { lat: 22.3, lng: 114.2 }, + HN: { lat: 15.0, lng: -86.5 }, + HR: { lat: 45.2, lng: 15.5 }, + HT: { lat: 19.0, lng: -72.4 }, + HU: { lat: 47.0, lng: 20.0 }, + ID: { lat: -5.0, lng: 120.0 }, + IE: { lat: 53.0, lng: -8.0 }, + IL: { lat: 31.5, lng: 34.8 }, + IN: { lat: 20.0, lng: 77.0 }, + IQ: { lat: 33.0, lng: 44.0 }, + IR: { lat: 32.0, lng: 53.0 }, + IS: { lat: 65.0, lng: -18.0 }, + IT: { lat: 42.8, lng: 12.8 }, + JM: { lat: 18.3, lng: -77.4 }, + JO: { lat: 31.0, lng: 36.0 }, + JP: { lat: 36.0, lng: 138.0 }, + KE: { lat: 1.0, lng: 38.0 }, + KG: { lat: 41.0, lng: 75.0 }, + KH: { lat: 13.0, lng: 105.0 }, + KI: { lat: 1.4, lng: 173.0 }, + KM: { lat: -12.2, lng: 44.2 }, + KN: { lat: 17.3, lng: -62.7 }, + KP: { lat: 40.0, lng: 127.0 }, + KR: { lat: 37.0, lng: 127.5 }, + KW: { lat: 29.5, lng: 47.8 }, + KZ: { lat: 48.0, lng: 68.0 }, + LA: { lat: 18.0, lng: 105.0 }, + LB: { lat: 33.9, lng: 35.8 }, + LC: { lat: 13.9, lng: -61.0 }, + LI: { lat: 47.2, lng: 9.5 }, + LK: { lat: 7.0, lng: 81.0 }, + LR: { lat: 6.5, lng: -9.5 }, + LS: { lat: -29.5, lng: 28.5 }, + LT: { lat: 56.0, lng: 24.0 }, + LU: { lat: 49.8, lng: 6.2 }, + LV: { lat: 57.0, lng: 25.0 }, + LY: { lat: 25.0, lng: 17.0 }, + MA: { lat: 32.0, lng: -5.0 }, + MC: { lat: 43.7, lng: 7.4 }, + MD: { lat: 47.0, lng: 29.0 }, + ME: { lat: 42.5, lng: 19.3 }, + MG: { lat: -20.0, lng: 47.0 }, + MK: { lat: 41.8, lng: 22.0 }, + ML: { lat: 17.0, lng: -4.0 }, + MM: { lat: 22.0, lng: 98.0 }, + MN: { lat: 46.0, lng: 105.0 }, + MO: { lat: 22.2, lng: 113.5 }, + MR: { lat: 20.0, lng: -12.0 }, + MT: { lat: 35.9, lng: 14.4 }, + MU: { lat: -20.3, lng: 57.6 }, + MV: { lat: 3.2, lng: 73.2 }, + MW: { lat: -13.5, lng: 34.0 }, + MX: { lat: 23.0, lng: -102.0 }, + MY: { lat: 2.5, lng: 112.5 }, + MZ: { lat: -18.3, lng: 35.0 }, + NA: { lat: -22.0, lng: 17.0 }, + NE: { lat: 16.0, lng: 8.0 }, + NG: { lat: 10.0, lng: 8.0 }, + NI: { lat: 13.0, lng: -85.0 }, + NL: { lat: 52.5, lng: 5.8 }, + NO: { lat: 62.0, lng: 10.0 }, + NP: { lat: 28.0, lng: 84.0 }, + NR: { lat: -0.5, lng: 166.9 }, + NZ: { lat: -41.0, lng: 174.0 }, + OM: { lat: 21.0, lng: 57.0 }, + PA: { lat: 9.0, lng: -80.0 }, + PE: { lat: -10.0, lng: -76.0 }, + PG: { lat: -6.0, lng: 147.0 }, + PH: { lat: 13.0, lng: 122.0 }, + PK: { lat: 30.0, lng: 70.0 }, + PL: { lat: 52.0, lng: 20.0 }, + PR: { lat: 18.3, lng: -66.6 }, + PS: { lat: 31.9, lng: 35.2 }, + PT: { lat: 39.5, lng: -8.0 }, + PW: { lat: 7.5, lng: 134.6 }, + PY: { lat: -23.0, lng: -58.0 }, + QA: { lat: 25.5, lng: 51.3 }, + RO: { lat: 46.0, lng: 25.0 }, + RS: { lat: 44.0, lng: 21.0 }, + RU: { lat: 60.0, lng: 100.0 }, + RW: { lat: -2.0, lng: 29.9 }, + SA: { lat: 24.0, lng: 45.0 }, + SB: { lat: -8.0, lng: 159.0 }, + SC: { lat: -4.7, lng: 55.5 }, + SD: { lat: 15.0, lng: 30.0 }, + SE: { lat: 62.0, lng: 15.0 }, + SG: { lat: 1.4, lng: 103.8 }, + SI: { lat: 46.1, lng: 15.0 }, + SK: { lat: 48.7, lng: 19.5 }, + SL: { lat: 8.5, lng: -11.8 }, + SM: { lat: 43.9, lng: 12.4 }, + SN: { lat: 14.5, lng: -14.5 }, + SO: { lat: 5.0, lng: 46.0 }, + SR: { lat: 4.0, lng: -56.0 }, + SS: { lat: 7.0, lng: 30.0 }, + ST: { lat: 1.0, lng: 7.0 }, + SV: { lat: 13.8, lng: -88.9 }, + SY: { lat: 35.0, lng: 38.0 }, + SZ: { lat: -26.5, lng: 31.5 }, + TD: { lat: 15.0, lng: 19.0 }, + TG: { lat: 8.0, lng: 1.2 }, + TH: { lat: 15.0, lng: 100.0 }, + TJ: { lat: 39.0, lng: 71.0 }, + TL: { lat: -8.8, lng: 126.0 }, + TM: { lat: 40.0, lng: 60.0 }, + TN: { lat: 34.0, lng: 9.0 }, + TO: { lat: -20.0, lng: -175.0 }, + TR: { lat: 39.0, lng: 35.0 }, + TT: { lat: 10.5, lng: -61.3 }, + TV: { lat: -8.0, lng: 178.0 }, + TW: { lat: 23.5, lng: 121.0 }, + TZ: { lat: -6.0, lng: 35.0 }, + UA: { lat: 49.0, lng: 32.0 }, + UG: { lat: 1.0, lng: 32.0 }, + US: { lat: 39.8, lng: -98.5 }, + UY: { lat: -33.0, lng: -56.0 }, + UZ: { lat: 41.0, lng: 64.0 }, + VA: { lat: 41.9, lng: 12.5 }, + VC: { lat: 13.3, lng: -61.2 }, + VE: { lat: 8.0, lng: -66.0 }, + VN: { lat: 16.0, lng: 108.0 }, + VU: { lat: -16.0, lng: 167.0 }, + WS: { lat: -13.8, lng: -172.1 }, + XK: { lat: 42.6, lng: 21.0 }, + YE: { lat: 15.0, lng: 48.0 }, + ZA: { lat: -29.0, lng: 24.0 }, + ZM: { lat: -15.0, lng: 28.0 }, + ZW: { lat: -20.0, lng: 30.0 }, +} diff --git a/package-lock.json b/package-lock.json index 482959d..1a32675 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "d3-scale": "^4.0.2", "framer-motion": "^12.23.26", "html-to-image": "^1.11.13", - "i18n-iso-countries": "^7.14.0", "iso-3166-2": "^1.0.0", "jspdf": "^4.0.0", "jspdf-autotable": "^5.0.7", @@ -27,9 +26,9 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "react-markdown": "^10.1.0", - "react-simple-maps": "^3.0.0", "recharts": "^2.15.0", "sonner": "^2.0.7", + "svg-dotted-map": "^2.0.1", "swr": "^2.3.3", "xlsx": "^0.18.5" }, @@ -41,7 +40,6 @@ "@types/node": "^20.14.12", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@types/react-simple-maps": "^3.0.6", "@vitejs/plugin-react": "^5.1.4", "autoprefixer": "^10.4.19", "eslint": "^9.39.2", @@ -4045,26 +4043,6 @@ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "license": "MIT" }, - "node_modules/@types/d3-geo": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-2.0.7.tgz", - "integrity": "sha512-RIXlxPdxvX+LAZFv+t78CuYpxYag4zuw9mZc+AwfB8tZpKU90rMEn2il2ADncmeZlb7nER9dDsJpRisA3lRvjA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/geojson": "*" - } - }, - "node_modules/@types/d3-interpolate": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.5.tgz", - "integrity": "sha512-UINE41RDaUMbulp+bxQMDnhOi51rh5lA2dG+dWZU0UY/IwQiG/u2x8TfnWYU9+xwGdXsJoAvrBYUEQl0r91atg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/d3-color": "^2" - } - }, "node_modules/@types/d3-path": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", @@ -4080,13 +4058,6 @@ "@types/d3-time": "*" } }, - "node_modules/@types/d3-selection": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-2.0.5.tgz", - "integrity": "sha512-71BorcY0yXl12S7lvb01JdaN9TpeUHBDb4RRhSq8U8BEkX/nIk5p7Byho+ZRTsx5nYLMpAbY3qt5EhqFzfGJlw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -4108,17 +4079,6 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, - "node_modules/@types/d3-zoom": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-2.0.7.tgz", - "integrity": "sha512-JWke4E8ZyrKUQ68ESTWSK16fVb0OYnaiJ+WXJRYxKLn4aXU0o4CLYxMWBEiouUfO3TTCoyroOrGPcBG6u1aAxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/d3-interpolate": "^2", - "@types/d3-selection": "^2" - } - }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -4170,13 +4130,6 @@ "@types/estree": "*" } }, - "node_modules/@types/geojson": { - "version": "7946.0.16", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", - "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -4257,19 +4210,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/react-simple-maps": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/react-simple-maps/-/react-simple-maps-3.0.6.tgz", - "integrity": "sha512-hR01RXt6VvsE41FxDd+Bqm1PPGdKbYjCYVtCgh38YeBPt46z3SwmWPWu2L3EdCAP6bd6VYEgztucihRw1C0Klg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/d3-geo": "^2", - "@types/d3-zoom": "^2", - "@types/geojson": "*", - "@types/react": "*" - } - }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -6320,28 +6260,6 @@ "node": ">=12" } }, - "node_modules/d3-dispatch": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz", - "integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-drag": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz", - "integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-dispatch": "1 - 2", - "d3-selection": "2" - } - }, - "node_modules/d3-ease": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz", - "integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==", - "license": "BSD-3-Clause" - }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -6351,30 +6269,6 @@ "node": ">=12" } }, - "node_modules/d3-geo": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz", - "integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^2.5.0" - } - }, - "node_modules/d3-geo/node_modules/d3-array": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", - "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", - "license": "BSD-3-Clause", - "dependencies": { - "internmap": "^1.0.0" - } - }, - "node_modules/d3-geo/node_modules/internmap": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", - "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", - "license": "ISC" - }, "node_modules/d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -6412,13 +6306,6 @@ "node": ">=12" } }, - "node_modules/d3-selection": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz", - "integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==", - "license": "BSD-3-Clause", - "peer": true - }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -6455,71 +6342,6 @@ "node": ">=12" } }, - "node_modules/d3-timer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz", - "integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-transition": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz", - "integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-color": "1 - 2", - "d3-dispatch": "1 - 2", - "d3-ease": "1 - 2", - "d3-interpolate": "1 - 2", - "d3-timer": "1 - 2" - }, - "peerDependencies": { - "d3-selection": "2" - } - }, - "node_modules/d3-transition/node_modules/d3-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz", - "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-transition/node_modules/d3-interpolate": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz", - "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-color": "1 - 2" - } - }, - "node_modules/d3-zoom": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz", - "integrity": "sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-dispatch": "1 - 2", - "d3-drag": "2", - "d3-interpolate": "1 - 2", - "d3-selection": "2", - "d3-transition": "2" - } - }, - "node_modules/d3-zoom/node_modules/d3-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz", - "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-zoom/node_modules/d3-interpolate": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz", - "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-color": "1 - 2" - } - }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -6764,12 +6586,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/diacritics": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz", - "integrity": "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==", - "license": "MIT" - }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -8387,18 +8203,6 @@ "node": ">= 14" } }, - "node_modules/i18n-iso-countries": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.14.0.tgz", - "integrity": "sha512-nXHJZYtNrfsi1UQbyRqm3Gou431elgLjKl//CYlnBGt5aTWdRPH1PiS2T/p/n8Q8LnqYqzQJik3Q7mkwvLokeg==", - "license": "MIT", - "dependencies": { - "diacritics": "1.3.0" - }, - "engines": { - "node": ">= 12" - } - }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -11140,23 +10944,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-simple-maps": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz", - "integrity": "sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==", - "license": "MIT", - "dependencies": { - "d3-geo": "^2.0.2", - "d3-selection": "^2.0.0", - "d3-zoom": "^2.0.0", - "topojson-client": "^3.1.0" - }, - "peerDependencies": { - "prop-types": "^15.7.2", - "react": "^16.8.0 || 17.x || 18.x", - "react-dom": "^16.8.0 || 17.x || 18.x" - } - }, "node_modules/react-smooth": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", @@ -12275,6 +12062,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-dotted-map": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/svg-dotted-map/-/svg-dotted-map-2.0.1.tgz", + "integrity": "sha512-eeI2XzIKm23gmSVr7ASTMNVJvxAvBfyL30tN33Y/DcZCJXvC/Br/cxQp9Ts6jDK/e7fkE5TpZStEfduPqPXrIw==" + }, "node_modules/svg-pathdata": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", @@ -12613,26 +12405,6 @@ "node": ">=8.0" } }, - "node_modules/topojson-client": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", - "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", - "license": "ISC", - "dependencies": { - "commander": "2" - }, - "bin": { - "topo2geo": "bin/topo2geo", - "topomerge": "bin/topomerge", - "topoquantize": "bin/topoquantize" - } - }, - "node_modules/topojson-client/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" - }, "node_modules/tough-cookie": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", diff --git a/package.json b/package.json index cffe2d8..fced60c 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "d3-scale": "^4.0.2", "framer-motion": "^12.23.26", "html-to-image": "^1.11.13", - "i18n-iso-countries": "^7.14.0", "iso-3166-2": "^1.0.0", "jspdf": "^4.0.0", "jspdf-autotable": "^5.0.7", @@ -31,9 +30,9 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "react-markdown": "^10.1.0", - "react-simple-maps": "^3.0.0", "recharts": "^2.15.0", "sonner": "^2.0.7", + "svg-dotted-map": "^2.0.1", "swr": "^2.3.3", "xlsx": "^0.18.5" }, @@ -51,7 +50,6 @@ "@types/node": "^20.14.12", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@types/react-simple-maps": "^3.0.6", "@vitejs/plugin-react": "^5.1.4", "autoprefixer": "^10.4.19", "eslint": "^9.39.2", From 31416f0eb26029e474f92600fb843695fa1f6ae0 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 14:23:48 +0100 Subject: [PATCH 019/109] Polish DottedMap: glow effect, tooltips, better fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SVG filter for orange glow behind markers - Hover tooltips showing country name + pageview count - Reduced viewBox height (75→68) to fill card better - Bumped mapSamples to 8000 for crisper landmass - Centered map vertically with flexbox wrapper Co-Authored-By: Claude Opus 4.6 --- components/dashboard/DottedMap.tsx | 131 ++++++++++++++++++++--------- 1 file changed, 93 insertions(+), 38 deletions(-) diff --git a/components/dashboard/DottedMap.tsx b/components/dashboard/DottedMap.tsx index dee2e67..f0799d8 100644 --- a/components/dashboard/DottedMap.tsx +++ b/components/dashboard/DottedMap.tsx @@ -1,8 +1,8 @@ 'use client' -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { createMap } from 'svg-dotted-map' -import { cn } from '@ciphera-net/ui' +import { cn, formatNumber } from '@ciphera-net/ui' import { countryCentroids } from '@/lib/country-centroids' interface DottedMapProps { @@ -10,14 +10,24 @@ interface DottedMapProps { className?: string } +function getCountryName(code: string): string { + try { + const regionNames = new Intl.DisplayNames(['en'], { type: 'region' }) + return regionNames.of(code) || code + } catch { + return code + } +} + export default function DottedMap({ data, className }: DottedMapProps) { const width = 150 - const height = 75 + const height = 68 const dotRadius = 0.2 + const [tooltip, setTooltip] = useState<{ x: number; y: number; country: string; pageviews: number } | null>(null) - const { points, addMarkers } = createMap({ width, height, mapSamples: 5000 }) + const { points, addMarkers } = createMap({ width, height, mapSamples: 8000 }) - const markers = useMemo(() => { + const markerData = useMemo(() => { if (!data.length) return [] const max = Math.max(...data.map((d) => d.pageviews)) @@ -29,10 +39,16 @@ export default function DottedMap({ data, className }: DottedMapProps) { lat: countryCentroids[d.country].lat, lng: countryCentroids[d.country].lng, size: 0.4 + (d.pageviews / max) * 0.8, + country: d.country, + pageviews: d.pageviews, })) }, [data]) - const processedMarkers = addMarkers(markers) + const markerInputs = useMemo( + () => markerData.map((d) => ({ lat: d.lat, lng: d.lng, size: d.size })), + [markerData], + ) + const processedMarkers = addMarkers(markerInputs) // Compute stagger helpers const { xStep, yToRowIndex } = useMemo(() => { @@ -59,37 +75,76 @@ export default function DottedMap({ data, className }: DottedMapProps) { }, [points]) return ( - - {points.map((point, index) => { - const rowIndex = yToRowIndex.get(point.y) ?? 0 - const offsetX = rowIndex % 2 === 1 ? xStep / 2 : 0 - return ( - - ) - })} - {processedMarkers.map((marker, index) => { - const rowIndex = yToRowIndex.get(marker.y) ?? 0 - const offsetX = rowIndex % 2 === 1 ? xStep / 2 : 0 - return ( - - ) - })} - +
+ + + + + + + + + + + + {points.map((point, index) => { + const rowIndex = yToRowIndex.get(point.y) ?? 0 + const offsetX = rowIndex % 2 === 1 ? xStep / 2 : 0 + return ( + + ) + })} + {processedMarkers.map((marker, index) => { + const rowIndex = yToRowIndex.get(marker.y) ?? 0 + const offsetX = rowIndex % 2 === 1 ? xStep / 2 : 0 + const info = markerData[index] + return ( + { + if (info) { + const rect = (e.target as SVGCircleElement).closest('svg')!.getBoundingClientRect() + const svgX = marker.x + offsetX + const svgY = marker.y + setTooltip({ + x: rect.left + (svgX / width) * rect.width, + y: rect.top + (svgY / height) * rect.height, + country: info.country, + pageviews: info.pageviews, + }) + } + }} + onMouseLeave={() => setTooltip(null)} + /> + ) + })} + + + {tooltip && ( +
+ {getCountryName(tooltip.country)} + {formatNumber(tooltip.pageviews)} +
+ )} +
) } From f58154f18d101244d82951548ff054d864b7186c Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 14:43:35 +0100 Subject: [PATCH 020/109] Bump @ciphera-net/ui to ^0.1.2 --- package-lock.json | 9 ++++----- package.json | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1a32675..490fbc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.13.0-alpha", "dependencies": { - "@ciphera-net/ui": "^0.1.1", + "@ciphera-net/ui": "^0.1.2", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", @@ -1662,9 +1662,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.1.1", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.1.1/03044e883ed2973c6664e19ff43d2969f1429bd4", - "integrity": "sha512-BM14HMUIVIiyj6YRQFeT07BP56dmHYXtMrSODMrNJ4l5+i6iAw3tbRJ9W5s+5JC1fxn6TbKN43XjTnMfTMHj2w==", + "version": "0.1.2", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.1.2/3c7b059065be5a3f4bda0aeab66be342433eefcd", + "integrity": "sha512-e8Iir4CPNJI1RP2pIzh41qLv6Ftig7c+b9OriI2Y+15g2GoaQjFzxzBFo+nyJuCj2QdvtHbkx8YpN+SLfnkPwA==", "dependencies": { "@phosphor-icons/react": "^2.1.10", "class-variance-authority": "^0.7.1", @@ -10807,7 +10807,6 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", diff --git a/package.json b/package.json index fced60c..f78b467 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ciphera-net/ui": "^0.1.1", + "@ciphera-net/ui": "^0.1.2", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", From c065853800dfd5409b765decafeb44c6e88a753b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 15:05:28 +0100 Subject: [PATCH 021/109] Make map landmass dots more prominent in both themes Co-Authored-By: Claude Opus 4.6 --- components/dashboard/DottedMap.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/dashboard/DottedMap.tsx b/components/dashboard/DottedMap.tsx index f0799d8..0a6d7b8 100644 --- a/components/dashboard/DottedMap.tsx +++ b/components/dashboard/DottedMap.tsx @@ -22,7 +22,7 @@ function getCountryName(code: string): string { export default function DottedMap({ data, className }: DottedMapProps) { const width = 150 const height = 68 - const dotRadius = 0.2 + const dotRadius = 0.25 const [tooltip, setTooltip] = useState<{ x: number; y: number; country: string; pageviews: number } | null>(null) const { points, addMarkers } = createMap({ width, height, mapSamples: 8000 }) @@ -78,7 +78,7 @@ export default function DottedMap({ data, className }: DottedMapProps) {
From df2f38eb839211f208ea3570ebb28e52c2fd2afb Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 15:34:23 +0100 Subject: [PATCH 022/109] Scale DottedMap SVG to fill full card height --- components/dashboard/DottedMap.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/dashboard/DottedMap.tsx b/components/dashboard/DottedMap.tsx index 0a6d7b8..f5cca77 100644 --- a/components/dashboard/DottedMap.tsx +++ b/components/dashboard/DottedMap.tsx @@ -79,7 +79,7 @@ export default function DottedMap({ data, className }: DottedMapProps) { From efd647d856735c147ff66e2d9c1c37cbaad08eae Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 15:46:29 +0100 Subject: [PATCH 023/109] Add interactive 3D Globe tab to Locations using cobe WebGL - Magic UI Globe component with auto-rotation and drag interaction - Dark/light mode reactive (base color, glow, brightness) - Country markers from visitor data using existing centroids - Brand orange (#FD5E0F) marker color matching DottedMap Co-Authored-By: Claude Opus 4.6 --- components/dashboard/Globe.tsx | 133 +++++++++++++++++++++++++++++ components/dashboard/Locations.tsx | 22 +++-- package-lock.json | 16 ++++ package.json | 1 + 4 files changed, 164 insertions(+), 8 deletions(-) create mode 100644 components/dashboard/Globe.tsx diff --git a/components/dashboard/Globe.tsx b/components/dashboard/Globe.tsx new file mode 100644 index 0000000..5f27e22 --- /dev/null +++ b/components/dashboard/Globe.tsx @@ -0,0 +1,133 @@ +'use client' + +import { useEffect, useRef, useMemo } from 'react' +import createGlobe, { type COBEOptions } from 'cobe' +import { useMotionValue, useSpring } from 'framer-motion' +import { useTheme } from '@ciphera-net/ui' +import { countryCentroids } from '@/lib/country-centroids' + +const MOVEMENT_DAMPING = 1400 + +interface GlobeProps { + data: Array<{ country: string; pageviews: number }> + className?: string +} + +export default function Globe({ data, className }: GlobeProps) { + const canvasRef = useRef(null) + const phiRef = useRef(0) + const widthRef = useRef(0) + const pointerInteracting = useRef(null) + const pointerInteractionMovement = useRef(0) + const { resolvedTheme } = useTheme() + + const isDark = resolvedTheme === 'dark' + + const markers = useMemo(() => { + if (!data.length) return [] + const max = Math.max(...data.map((d) => d.pageviews)) + if (max === 0) return [] + + return data + .filter((d) => d.country && d.country !== 'Unknown' && countryCentroids[d.country]) + .map((d) => ({ + location: [countryCentroids[d.country].lat, countryCentroids[d.country].lng] as [number, number], + size: 0.03 + (d.pageviews / max) * 0.12, + })) + }, [data]) + + const r = useMotionValue(0) + const rs = useSpring(r, { + mass: 1, + damping: 30, + stiffness: 100, + }) + + const updatePointerInteraction = (value: number | null) => { + pointerInteracting.current = value + if (canvasRef.current) { + canvasRef.current.style.cursor = value !== null ? 'grabbing' : 'grab' + } + } + + const updateMovement = (clientX: number) => { + if (pointerInteracting.current !== null) { + const delta = clientX - pointerInteracting.current + pointerInteractionMovement.current = delta + r.set(r.get() + delta / MOVEMENT_DAMPING) + } + } + + useEffect(() => { + if (!canvasRef.current) return + + const onResize = () => { + if (canvasRef.current) { + widthRef.current = canvasRef.current.offsetWidth + } + } + + window.addEventListener('resize', onResize) + onResize() + + const config: COBEOptions = { + width: widthRef.current * 2, + height: widthRef.current * 2, + onRender: () => {}, + devicePixelRatio: 2, + phi: 0, + theta: 0.3, + dark: isDark ? 1 : 0, + diffuse: 0.4, + mapSamples: 16000, + mapBrightness: isDark ? 1.8 : 1.2, + baseColor: isDark ? [0.15, 0.15, 0.15] : [1, 1, 1], + markerColor: [253 / 255, 94 / 255, 15 / 255], + glowColor: isDark ? [0.15, 0.15, 0.15] : [1, 1, 1], + markers, + } + + const globe = createGlobe(canvasRef.current, { + ...config, + width: widthRef.current * 2, + height: widthRef.current * 2, + onRender: (state) => { + if (!pointerInteracting.current) phiRef.current += 0.005 + state.phi = phiRef.current + rs.get() + state.width = widthRef.current * 2 + state.height = widthRef.current * 2 + }, + }) + + setTimeout(() => { + if (canvasRef.current) canvasRef.current.style.opacity = '1' + }, 0) + + return () => { + globe.destroy() + window.removeEventListener('resize', onResize) + } + }, [rs, markers, isDark]) + + return ( +
+
+ { + pointerInteracting.current = e.clientX + updatePointerInteraction(e.clientX) + }} + onPointerUp={() => updatePointerInteraction(null)} + onPointerOut={() => updatePointerInteraction(null)} + onMouseMove={(e) => updateMovement(e.clientX)} + onTouchMove={(e) => + e.touches[0] && updateMovement(e.touches[0].clientX) + } + /> +
+
+ ) +} diff --git a/components/dashboard/Locations.tsx b/components/dashboard/Locations.tsx index faef66b..b1d3cfe 100644 --- a/components/dashboard/Locations.tsx +++ b/components/dashboard/Locations.tsx @@ -7,6 +7,7 @@ import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard' import * as Flags from 'country-flag-icons/react/3x2' import iso3166 from 'iso-3166-2' import DottedMap from './DottedMap' +import Globe from './Globe' import { Modal, GlobeIcon } from '@ciphera-net/ui' import { ListSkeleton } from '@/components/skeletons' import { ShieldCheck, Detective, Broadcast } from '@phosphor-icons/react' @@ -23,7 +24,7 @@ interface LocationProps { onFilter?: (filter: DimensionFilter) => void } -type Tab = 'map' | 'countries' | 'regions' | 'cities' +type Tab = 'map' | 'globe' | 'countries' | 'regions' | 'cities' const LIMIT = 7 @@ -173,15 +174,16 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' }) } - const rawData = activeTab === 'map' ? [] : getData() + const isVisualTab = activeTab === 'map' || activeTab === 'globe' + const rawData = isVisualTab ? [] : getData() const data = filterUnknown(rawData) const totalPageviews = data.reduce((sum, item) => sum + item.pageviews, 0) - const hasData = activeTab === 'map' + const hasData = isVisualTab ? (countries && filterUnknown(countries).length > 0) : (data && data.length > 0) - const displayedData = (activeTab !== 'map' && hasData) ? data.slice(0, LIMIT) : [] + const displayedData = (!isVisualTab && hasData) ? data.slice(0, LIMIT) : [] const emptySlots = Math.max(0, LIMIT - displayedData.length) - const showViewAll = activeTab !== 'map' && hasData && data.length > LIMIT + const showViewAll = !isVisualTab && hasData && data.length > LIMIT const getDisabledMessage = () => { if (geoDataLevel === 'none') { @@ -201,7 +203,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' Locations
- {(['map', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => ( + {(['map', 'globe', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => ( - ) - })} -
- - {/* Chart Area */} -
- {/* Toolbar */} -
- {/* Left: metric label + avg badge */} -
- - {metricLabel} - - {hasPrev && ( -
- - - Current - - - - Previous{prevPeriodLabel ? ` (${prevPeriodLabel})` : ''} - -
- )} -
- - {/* Right: controls */} -
- {dateRange.start === dateRange.end ? ( - setMultiDayInterval(value as 'hour' | 'day')} - options={[ - { value: 'hour', label: '1 hour' }, - { value: 'day', label: '1 day' }, - ]} - className="min-w-[90px]" - /> - )} - - {prevData?.length ? ( - - ) : null} - - - - {canManageAnnotations && ( +
+ + + {/* Metrics Grid - 21st.dev style */} +
+ {metricsWithTrends.map((m) => ( - )} -
-
- - {!hasData ? ( -
- -

No data for this period

-
- ) : !hasAnyNonZero ? ( -
- -

No {metricLabel.toLowerCase()} recorded

-
- ) : ( -
- - - - - - } - /> - {hasPrev && ( - + key={m.key} + onClick={() => setMetric(m.key)} + className={cn( + 'cursor-pointer flex-1 text-start p-4 last:border-b-0 border-b @2xl:border-b @2xl:even:border-r @3xl:border-b-0 @3xl:border-r @3xl:last:border-r-0 border-neutral-200 dark:border-neutral-800 transition-all', + metric === m.key && 'bg-neutral-50 dark:bg-neutral-800/40', )} - - {annotationMarkers.map((marker) => { - const primaryCategory = marker.annotations[0].category - const color = ANNOTATION_COLORS[primaryCategory] || ANNOTATION_COLORS.other - return ( - - ) - })} - - + > +
+ {m.label} + {m.change !== null && ( + + {m.isPositive ? : } + {Math.abs(m.change).toFixed(1)}% + + )} +
+
{m.format(m.value)}
+ {m.previousValue != null && ( +
from {m.format(m.previousValue)}
+ )} + + ))}
- )} -
+ + + {/* Toolbar */} +
+
+ + {METRIC_CONFIGS.find((m) => m.key === metric)?.label} + +
+
+ {dateRange.start === dateRange.end ? ( + setMultiDayInterval(value as 'hour' | 'day')} + options={[ + { value: 'hour', label: '1 hour' }, + { value: 'day', label: '1 day' }, + ]} + className="min-w-[90px]" + /> + )} + + {prevData?.length ? ( + + ) : null} + + + + {canManageAnnotations && ( + + )} +
+
+ + {!hasData || !hasAnyNonZero ? ( +
+

+ {!hasData ? 'No data for this period' : `No ${METRIC_CONFIGS.find((m) => m.key === metric)?.label.toLowerCase()} recorded`} +

+
+ ) : ( +
+ + + + + + + + + + + + + + + + + { + const config = METRIC_CONFIGS.find((m) => m.key === metric) + return config ? config.format(value) : value.toString() + }} + /> + + } cursor={{ strokeDasharray: '3 3', stroke: '#9ca3af' }} /> + + {/* Background dot grid pattern */} + + + {/* Annotation reference lines */} + {annotationMarkers.map((marker) => { + const primaryCategory = marker.annotations[0].category + const color = ANNOTATION_COLORS[primaryCategory] || ANNOTATION_COLORS.other + return ( + + ) + })} + + + + +
+ )} +
+ + + {/* Annotation tags */} {annotationMarkers.length > 0 && ( -
+
Annotations: {annotationMarkers.map((marker) => { const primary = marker.annotations[0] @@ -617,7 +548,6 @@ export default function Chart({ {primary.text} {count > 1 && +{count - 1}} - {/* Hover tooltip */}
{marker.annotations.map((a) => ( @@ -641,7 +571,7 @@ export default function Chart({ {/* Live indicator */} {lastUpdatedAt != null && ( -
+
@@ -692,7 +622,6 @@ export default function Chart({ {annotationForm.editingId ? 'Edit annotation' : 'Add annotation'}
- {/* Date picker trigger */}
- {/* Time input */}
- {/* Note */}
{annotationForm.text.length}/200
- {/* Category - custom Select */}
setModalSearch(e.target.value)} + placeholder="Search campaigns..." + className="w-full px-3 py-2 mb-3 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 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50" + /> +
{isLoadingFull ? (
) : (() => { - const modalTotal = sortedFullData.reduce((sum, item) => sum + item.visitors, 0) + const filteredCampaigns = !modalSearch ? sortedFullData : sortedFullData.filter(item => { + const search = modalSearch.toLowerCase() + return item.source.toLowerCase().includes(search) || (item.medium || '').toLowerCase().includes(search) || (item.campaign || '').toLowerCase().includes(search) + }) + const modalTotal = filteredCampaigns.reduce((sum, item) => sum + item.visitors, 0) return ( <>
@@ -232,7 +246,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp Export CSV
- {sortedFullData.map((item) => ( + {filteredCampaigns.map((item) => (
{ if (onFilter) { onFilter({ dimension: 'utm_source', operator: 'is', values: [item.source] }); setIsModalOpen(false) } }} diff --git a/components/dashboard/ContentStats.tsx b/components/dashboard/ContentStats.tsx index 4295a9d..7ab1989 100644 --- a/components/dashboard/ContentStats.tsx +++ b/components/dashboard/ContentStats.tsx @@ -30,6 +30,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain, const [activeTab, setActiveTab] = useState('top_pages') const handleTabKeyDown = useTabListKeyboard() const [isModalOpen, setIsModalOpen] = useState(false) + const [modalSearch, setModalSearch] = useState('') const [fullData, setFullData] = useState([]) const [isLoadingFull, setIsLoadingFull] = useState(false) @@ -195,17 +196,26 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain, setIsModalOpen(false)} + onClose={() => { setIsModalOpen(false); setModalSearch('') }} title={`Pages - ${getTabLabel(activeTab)}`} className="max-w-2xl" > +
+ setModalSearch(e.target.value)} + placeholder="Search pages..." + className="w-full px-3 py-2 mb-3 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 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50" + /> +
{isLoadingFull ? (
) : (() => { - const modalData = fullData.length > 0 ? fullData : data + const modalData = (fullData.length > 0 ? fullData : data).filter(p => !modalSearch || p.path.toLowerCase().includes(modalSearch.toLowerCase())) const modalTotal = modalData.reduce((sum, p) => sum + p.pageviews, 0) return modalData.map((page) => { const canFilter = onFilter && page.path diff --git a/components/dashboard/Locations.tsx b/components/dashboard/Locations.tsx index 717383b..410f039 100644 --- a/components/dashboard/Locations.tsx +++ b/components/dashboard/Locations.tsx @@ -37,6 +37,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' const [activeTab, setActiveTab] = useState('map') const handleTabKeyDown = useTabListKeyboard() const [isModalOpen, setIsModalOpen] = useState(false) + const [modalSearch, setModalSearch] = useState('') type LocationItem = { country?: string; city?: string; region?: string; pageviews: number } const [fullData, setFullData] = useState([]) const [isLoadingFull, setIsLoadingFull] = useState(false) @@ -320,17 +321,31 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' setIsModalOpen(false)} + onClose={() => { setIsModalOpen(false); setModalSearch('') }} title={`Locations - ${activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}`} className="max-w-2xl" > +
+ setModalSearch(e.target.value)} + placeholder="Search locations..." + className="w-full px-3 py-2 mb-3 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 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50" + /> +
{isLoadingFull ? (
) : (() => { - const modalData = fullData.length > 0 ? fullData : data + const rawModalData = fullData.length > 0 ? fullData : data + const search = modalSearch.toLowerCase() + const modalData = !modalSearch ? rawModalData : rawModalData.filter(item => { + const label = activeTab === 'countries' ? getCountryName(item.country ?? '') : activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') : getCityName(item.city ?? '') + return label.toLowerCase().includes(search) + }) const modalTotal = modalData.reduce((sum, item) => sum + item.pageviews, 0) return modalData.map((item) => { const dim = TAB_TO_DIMENSION[activeTab] diff --git a/components/dashboard/TechSpecs.tsx b/components/dashboard/TechSpecs.tsx index 880548d..d969b4f 100644 --- a/components/dashboard/TechSpecs.tsx +++ b/components/dashboard/TechSpecs.tsx @@ -39,6 +39,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co const [activeTab, setActiveTab] = useState('browsers') const handleTabKeyDown = useTabListKeyboard() const [isModalOpen, setIsModalOpen] = useState(false) + const [modalSearch, setModalSearch] = useState('') type TechItem = { name: string; pageviews: number; icon: React.ReactNode } const [fullData, setFullData] = useState([]) const [isLoadingFull, setIsLoadingFull] = useState(false) @@ -221,17 +222,26 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co setIsModalOpen(false)} + onClose={() => { setIsModalOpen(false); setModalSearch('') }} title={`Technology - ${activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}`} className="max-w-2xl" > +
+ setModalSearch(e.target.value)} + placeholder="Search technology..." + className="w-full px-3 py-2 mb-3 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 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50" + /> +
{isLoadingFull ? (
) : (() => { - const modalData = fullData.length > 0 ? fullData : data + const modalData = (fullData.length > 0 ? fullData : data).filter(item => !modalSearch || item.name.toLowerCase().includes(modalSearch.toLowerCase())) const modalTotal = modalData.reduce((sum, item) => sum + item.pageviews, 0) const dim = TAB_TO_DIMENSION[activeTab] return modalData.map((item) => { diff --git a/components/dashboard/TopReferrers.tsx b/components/dashboard/TopReferrers.tsx index 15918ef..c5df9e4 100644 --- a/components/dashboard/TopReferrers.tsx +++ b/components/dashboard/TopReferrers.tsx @@ -23,6 +23,7 @@ const LIMIT = 7 export default function TopReferrers({ referrers, collectReferrers = true, siteId, dateRange, onFilter }: TopReferrersProps) { const [isModalOpen, setIsModalOpen] = useState(false) + const [modalSearch, setModalSearch] = useState('') const [fullData, setFullData] = useState([]) const [isLoadingFull, setIsLoadingFull] = useState(false) const [faviconFailed, setFaviconFailed] = useState>(new Set()) @@ -151,17 +152,26 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI setIsModalOpen(false)} + onClose={() => { setIsModalOpen(false); setModalSearch('') }} title="Referrers" className="max-w-2xl" > +
+ setModalSearch(e.target.value)} + placeholder="Search referrers..." + className="w-full px-3 py-2 mb-3 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 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50" + /> +
{isLoadingFull ? (
) : (() => { - const modalData = mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers) + const modalData = mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).filter(r => !modalSearch || getReferrerDisplayName(r.referrer).toLowerCase().includes(modalSearch.toLowerCase())) const modalTotal = modalData.reduce((sum, r) => sum + r.pageviews, 0) return modalData.map((ref) => (
Date: Tue, 10 Mar 2026 01:46:31 +0100 Subject: [PATCH 064/109] Fix modal titles, hover rounding, search focus, and page filter dimension - Remove redundant prefixes from modal titles - Remove -mx-2 from modal rows so hover rounds evenly on both sides - Fix page filter using wrong dimension name (path -> page) - Bump @ciphera-net/ui to 0.1.3 (fixes search input losing focus) Co-Authored-By: Claude Opus 4.6 --- components/dashboard/Campaigns.tsx | 4 ++-- components/dashboard/ContentStats.tsx | 6 +++--- components/dashboard/Locations.tsx | 4 ++-- components/dashboard/TechSpecs.tsx | 4 ++-- components/dashboard/TopReferrers.tsx | 2 +- package-lock.json | 8 ++++---- package.json | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/components/dashboard/Campaigns.tsx b/components/dashboard/Campaigns.tsx index 87bad2b..7e4b35c 100644 --- a/components/dashboard/Campaigns.tsx +++ b/components/dashboard/Campaigns.tsx @@ -213,7 +213,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp { setIsModalOpen(false); setModalSearch('') }} - title="All Campaigns" + title="Campaigns" className="max-w-2xl" >
@@ -250,7 +250,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
{ if (onFilter) { onFilter({ dimension: 'utm_source', operator: 'is', values: [item.source] }); setIsModalOpen(false) } }} - className={`flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} + className={`flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} >
{renderSourceIcon(item.source)} diff --git a/components/dashboard/ContentStats.tsx b/components/dashboard/ContentStats.tsx index 7ab1989..ab8a029 100644 --- a/components/dashboard/ContentStats.tsx +++ b/components/dashboard/ContentStats.tsx @@ -197,7 +197,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain, { setIsModalOpen(false); setModalSearch('') }} - title={`Pages - ${getTabLabel(activeTab)}`} + title={getTabLabel(activeTab)} className="max-w-2xl" >
@@ -222,8 +222,8 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain, return (
{ if (canFilter) { onFilter({ dimension: 'path', operator: 'is', values: [page.path] }); setIsModalOpen(false) } }} - className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} + onClick={() => { if (canFilter) { onFilter({ dimension: 'page', operator: 'is', values: [page.path] }); setIsModalOpen(false) } }} + className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} >
{page.path} diff --git a/components/dashboard/Locations.tsx b/components/dashboard/Locations.tsx index 410f039..f9d3406 100644 --- a/components/dashboard/Locations.tsx +++ b/components/dashboard/Locations.tsx @@ -322,7 +322,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' { setIsModalOpen(false); setModalSearch('') }} - title={`Locations - ${activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}`} + title={activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} className="max-w-2xl" >
@@ -355,7 +355,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
{ if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [filterValue!] }); setIsModalOpen(false) } }} - className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} + className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} >
{getFlagComponent(item.country ?? '')} diff --git a/components/dashboard/TechSpecs.tsx b/components/dashboard/TechSpecs.tsx index d969b4f..59cdec4 100644 --- a/components/dashboard/TechSpecs.tsx +++ b/components/dashboard/TechSpecs.tsx @@ -223,7 +223,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co { setIsModalOpen(false); setModalSearch('') }} - title={`Technology - ${activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}`} + title={activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} className="max-w-2xl" >
@@ -250,7 +250,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
{ if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [item.name] }); setIsModalOpen(false) } }} - className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} + className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} >
{item.icon && {item.icon}} diff --git a/components/dashboard/TopReferrers.tsx b/components/dashboard/TopReferrers.tsx index c5df9e4..83b172b 100644 --- a/components/dashboard/TopReferrers.tsx +++ b/components/dashboard/TopReferrers.tsx @@ -177,7 +177,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
{ if (onFilter) { onFilter({ dimension: 'referrer', operator: 'is', values: [ref.referrer] }); setIsModalOpen(false) } }} - className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} + className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} >
{renderReferrerIcon(ref.referrer)} diff --git a/package-lock.json b/package-lock.json index 6d307ce..89e22e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.13.0-alpha", "dependencies": { - "@ciphera-net/ui": "^0.1.2", + "@ciphera-net/ui": "^0.1.3", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", @@ -1666,9 +1666,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.1.2", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.1.2/3c7b059065be5a3f4bda0aeab66be342433eefcd", - "integrity": "sha512-e8Iir4CPNJI1RP2pIzh41qLv6Ftig7c+b9OriI2Y+15g2GoaQjFzxzBFo+nyJuCj2QdvtHbkx8YpN+SLfnkPwA==", + "version": "0.1.3", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.1.3/2fb3a50f2648b9206d662d2dfdeb1aed4a0e27af", + "integrity": "sha512-JPM2XvErK6iW7XEJ81jEc2wqF4/Ej9o33f1xTc4MkzdMX5H81kLkUpp9TrwL/m6LvZ85Baj3853osnOvm8Y1Hw==", "dependencies": { "@phosphor-icons/react": "^2.1.10", "class-variance-authority": "^0.7.1", diff --git a/package.json b/package.json index 917e3f4..3b4a9fb 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ciphera-net/ui": "^0.1.2", + "@ciphera-net/ui": "^0.1.3", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", From d863004d5fcb0abfcb3c397954338524269390b6 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 10 Mar 2026 17:55:29 +0100 Subject: [PATCH 065/109] perf: consolidate 7 dashboard hooks into single batch request Replace useDashboardOverview, useDashboardPages, useDashboardLocations, useDashboardDevices, useDashboardReferrers, useDashboardPerformance, and useDashboardGoals with a single useDashboard hook that calls the existing /dashboard batch endpoint. This endpoint runs all queries in parallel on the backend and caches the result in Redis (30s TTL). Reduces dashboard requests from 12 to 6 per refresh cycle (50% reduction). At 1,000 concurrent users: ~6,000 req/min instead of 12,000. --- CHANGELOG.md | 4 ++ app/sites/[id]/page.tsx | 94 ++++++++++++++++++----------------------- lib/api/stats.ts | 4 +- lib/swr/dashboard.ts | 16 ++++--- 4 files changed, 56 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a8c3fa..217b39c 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] +### Improved + +- **Even faster dashboard loading.** Your dashboard now fetches all its data — pages, locations, devices, referrers, performance, and goals — in a single request instead of seven separate ones. This means the entire dashboard appears at once rather than sections loading one by one, and puts much less strain on the server when many people are viewing their analytics at the same time. + ### Added - **Interactive 3D Globe.** The Locations panel now has a "Globe" tab showing your visitor locations on a beautiful, interactive 3D globe. Drag to rotate, and orange markers highlight where your visitors are — sized by how much traffic each country sends. The globe slowly auto-rotates and adapts to light and dark mode. diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index 0488ecb..a220bc3 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -40,13 +40,7 @@ const EventProperties = dynamic(() => import('@/components/dashboard/EventProper const ExportModal = dynamic(() => import('@/components/dashboard/ExportModal')) import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters' import { - useDashboardOverview, - useDashboardPages, - useDashboardLocations, - useDashboardDevices, - useDashboardReferrers, - useDashboardPerformance, - useDashboardGoals, + useDashboard, useRealtime, useStats, useDailyStats, @@ -220,16 +214,10 @@ export default function SiteDashboardPage() { return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] } }, [dateRange]) - // SWR hooks - replace manual useState + useEffect + setInterval polling - // Each hook handles its own refresh interval, deduplication, and error retry - // 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) + // Single dashboard request replaces 7 focused hooks (overview, pages, locations, + // devices, referrers, performance, goals). The backend runs all queries in parallel + // and caches the result in Redis, reducing requests from 12 to 6 per refresh cycle. + const { data: dashboard, isLoading: dashboardLoading, error: dashboardError } = useDashboard(siteId, dateRange.start, dateRange.end, interval, 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) @@ -255,24 +243,24 @@ export default function SiteDashboardPage() { toast.success('Annotation deleted') } - // Derive typed values from SWR data - const site = overview?.site ?? null - const stats: Stats = overview?.stats ?? { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 } - const realtime = realtimeData?.visitors ?? overview?.realtime_visitors ?? 0 - const dailyStats: DailyStat[] = overview?.daily_stats ?? [] + // Derive typed values from single dashboard response + const site = dashboard?.site ?? null + const stats: Stats = dashboard?.stats ?? { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 } + const realtime = realtimeData?.visitors ?? dashboard?.realtime_visitors ?? 0 + const dailyStats: DailyStat[] = dashboard?.daily_stats ?? [] // Build filter suggestions from current dashboard data const filterSuggestions = useMemo(() => { const s: FilterSuggestions = {} // Pages - const topPages = pages?.top_pages ?? [] + const topPages = dashboard?.top_pages ?? [] if (topPages.length > 0) { s.page = topPages.map(p => ({ value: p.path, label: p.path, count: p.pageviews })) } // Referrers - const refs = referrers?.top_referrers ?? [] + const refs = dashboard?.top_referrers ?? [] if (refs.length > 0) { s.referrer = refs.filter(r => r.referrer && r.referrer !== '').map(r => ({ value: r.referrer, @@ -282,7 +270,7 @@ export default function SiteDashboardPage() { } // Countries - const ctrs = locations?.countries ?? [] + const ctrs = dashboard?.countries ?? [] if (ctrs.length > 0) { const regionNames = (() => { try { return new Intl.DisplayNames(['en'], { type: 'region' }) } catch { return null } })() s.country = ctrs.filter(c => c.country && c.country !== 'Unknown').map(c => ({ @@ -293,7 +281,7 @@ export default function SiteDashboardPage() { } // Regions - const regs = locations?.regions ?? [] + const regs = dashboard?.regions ?? [] if (regs.length > 0) { s.region = regs.filter(r => r.region && r.region !== 'Unknown').map(r => ({ value: r.region, @@ -303,7 +291,7 @@ export default function SiteDashboardPage() { } // Cities - const cts = locations?.cities ?? [] + const cts = dashboard?.cities ?? [] if (cts.length > 0) { s.city = cts.filter(c => c.city && c.city !== 'Unknown').map(c => ({ value: c.city, @@ -313,7 +301,7 @@ export default function SiteDashboardPage() { } // Browsers - const brs = devicesData?.browsers ?? [] + const brs = dashboard?.browsers ?? [] if (brs.length > 0) { s.browser = brs.filter(b => b.browser && b.browser !== 'Unknown').map(b => ({ value: b.browser, @@ -323,7 +311,7 @@ export default function SiteDashboardPage() { } // OS - const oses = devicesData?.os ?? [] + const oses = dashboard?.os ?? [] if (oses.length > 0) { s.os = oses.filter(o => o.os && o.os !== 'Unknown').map(o => ({ value: o.os, @@ -333,7 +321,7 @@ export default function SiteDashboardPage() { } // Devices - const devs = devicesData?.devices ?? [] + const devs = dashboard?.devices ?? [] if (devs.length > 0) { s.device = devs.filter(d => d.device && d.device !== 'Unknown').map(d => ({ value: d.device, @@ -359,19 +347,19 @@ export default function SiteDashboardPage() { } return s - }, [pages, referrers, locations, devicesData, campaigns]) + }, [dashboard, campaigns]) // Show error toast on fetch failure useEffect(() => { - if (overviewError) { + if (dashboardError) { toast.error('Failed to load dashboard analytics') } - }, [overviewError]) + }, [dashboardError]) // Track when data was last updated (for "Live · Xs ago" display) useEffect(() => { - if (overview) lastUpdatedAtRef.current = Date.now() - }, [overview]) + if (dashboard) lastUpdatedAtRef.current = Date.now() + }, [dashboard]) // Save settings to localStorage const saveSettings = (type: string, newDateRange?: { start: string; end: string }) => { @@ -413,7 +401,7 @@ export default function SiteDashboardPage() { // Skip the minimum-loading skeleton when SWR already has cached data // (prevents the 300ms flash when navigating back to the dashboard) - const showSkeleton = useMinimumLoading(overviewLoading && !overview) + const showSkeleton = useMinimumLoading(dashboardLoading && !dashboard) if (showSkeleton) { return @@ -543,8 +531,8 @@ export default function SiteDashboardPage() { {site.enable_performance_insights && (
!/^scroll_\d+$/.test(g.event_name))} + goalCounts={(dashboard?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))} onSelectEvent={setSelectedEvent} />
- +
{/* Event Properties Breakdown */} @@ -636,8 +624,8 @@ export default function SiteDashboardPage() { onClose={() => setIsExportModalOpen(false)} data={dailyStats} stats={stats} - topPages={pages?.top_pages} - topReferrers={referrers?.top_referrers} + topPages={dashboard?.top_pages} + topReferrers={dashboard?.top_referrers} campaigns={campaigns} />
diff --git a/lib/api/stats.ts b/lib/api/stats.ts index 5e66122..883e87f 100644 --- a/lib/api/stats.ts +++ b/lib/api/stats.ts @@ -245,8 +245,8 @@ export interface DashboardData { goal_counts?: GoalCountStat[] } -export function getDashboard(siteId: string, startDate?: string, endDate?: string, limit = 10, interval?: string): Promise { - return apiRequest(`/sites/${siteId}/dashboard${buildQuery({ startDate, endDate, limit, interval })}`) +export function getDashboard(siteId: string, startDate?: string, endDate?: string, limit = 10, interval?: string, filters?: string): Promise { + return apiRequest(`/sites/${siteId}/dashboard${buildQuery({ startDate, endDate, limit, interval, filters })}`) } export function getPublicDashboard( diff --git a/lib/swr/dashboard.ts b/lib/swr/dashboard.ts index 1911bb8..e6e9813 100644 --- a/lib/swr/dashboard.ts +++ b/lib/swr/dashboard.ts @@ -24,6 +24,7 @@ import type { Stats, DailyStat, CampaignStat, + DashboardData, DashboardOverviewData, DashboardPagesData, DashboardLocationsData, @@ -36,7 +37,7 @@ import type { // * SWR fetcher functions const fetchers = { site: (siteId: string) => getSite(siteId), - dashboard: (siteId: string, start: string, end: string) => getDashboard(siteId, start, end), + dashboard: (siteId: string, start: string, end: string, interval?: string, filters?: string) => getDashboard(siteId, start, end, 10, interval, filters), 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), @@ -81,14 +82,15 @@ export function useSite(siteId: string) { ) } -// * Hook for dashboard summary data (refreshed less frequently) -export function useDashboard(siteId: string, start: string, end: string) { - return useSWR( - siteId && start && end ? ['dashboard', siteId, start, end] : null, - () => fetchers.dashboard(siteId, start, end), +// * Hook for full dashboard data (single request replaces 7 focused hooks) +// * The backend runs all queries in parallel and caches the result in Redis (30s TTL) +export function useDashboard(siteId: string, start: string, end: string, interval?: string, filters?: string) { + return useSWR( + siteId && start && end ? ['dashboard', siteId, start, end, interval, filters] : null, + () => fetchers.dashboard(siteId, start, end, interval, filters), { ...dashboardSWRConfig, - // * Refresh every 60 seconds for dashboard summary + // * Refresh every 60 seconds for dashboard data refreshInterval: 60 * 1000, // * Deduping interval to prevent duplicate requests dedupingInterval: 10 * 1000, From bcaa5c25f884149a242c4f1ca166d82df90370ea Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 10 Mar 2026 18:33:17 +0100 Subject: [PATCH 066/109] perf: replace real-time polling with SSE streaming Replace 5-second setInterval polling with EventSource connection to the new /realtime/stream SSE endpoint. The server pushes visitor updates instead of each client independently polling. Auto-reconnects on connection drops. --- CHANGELOG.md | 1 + app/sites/[id]/realtime/page.tsx | 44 +++++++------------------- lib/hooks/useRealtimeSSE.ts | 53 ++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 33 deletions(-) create mode 100644 lib/hooks/useRealtimeSSE.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 217b39c..6b01664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Improved - **Even faster dashboard loading.** Your dashboard now fetches all its data — pages, locations, devices, referrers, performance, and goals — in a single request instead of seven separate ones. This means the entire dashboard appears at once rather than sections loading one by one, and puts much less strain on the server when many people are viewing their analytics at the same time. +- **Smoother real-time updates.** The real-time visitors page now streams updates instantly from the server instead of checking for new data every few seconds. Visitors appear and disappear in real-time with no delay, and the page uses far fewer server resources — especially when many people are watching their live traffic at the same time. ### Added diff --git a/app/sites/[id]/realtime/page.tsx b/app/sites/[id]/realtime/page.tsx index fb4b0da..0ef2c04 100644 --- a/app/sites/[id]/realtime/page.tsx +++ b/app/sites/[id]/realtime/page.tsx @@ -3,7 +3,8 @@ import { useEffect, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import { getSite, type Site } from '@/lib/api/sites' -import { getRealtimeVisitors, getSessionDetails, type Visitor, type SessionEvent } from '@/lib/api/realtime' +import { getSessionDetails, type Visitor, type SessionEvent } from '@/lib/api/realtime' +import { useRealtimeSSE } from '@/lib/hooks/useRealtimeSSE' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' import { UserIcon } from '@ciphera-net/ui' @@ -27,28 +28,20 @@ export default function RealtimePage() { const siteId = params.id as string const [site, setSite] = useState(null) - const [visitors, setVisitors] = useState([]) + const { visitors } = useRealtimeSSE(siteId) const [selectedVisitor, setSelectedVisitor] = useState(null) const [sessionEvents, setSessionEvents] = useState([]) const [loading, setLoading] = useState(true) const [loadingEvents, setLoadingEvents] = useState(false) - // Load site info and initial visitors + // Load site info useEffect(() => { const init = async () => { try { - const [siteData, visitorsData] = await Promise.all([ - getSite(siteId), - getRealtimeVisitors(siteId) - ]) + const siteData = await getSite(siteId) setSite(siteData) - setVisitors(visitorsData || []) - // Select first visitor if available - if (visitorsData && visitorsData.length > 0) { - handleSelectVisitor(visitorsData[0]) - } } catch (error: unknown) { - toast.error(getAuthErrorMessage(error) || 'Failed to load realtime visitors') + toast.error(getAuthErrorMessage(error) || 'Failed to load site') } finally { setLoading(false) } @@ -56,27 +49,12 @@ export default function RealtimePage() { init() }, [siteId]) - // Poll for updates + // Auto-select the first visitor when the list populates and nothing is selected useEffect(() => { - const interval = setInterval(async () => { - try { - const data = await getRealtimeVisitors(siteId) - setVisitors(data || []) - - // Update selected visitor reference if they are still in the list - if (selectedVisitor) { - const updatedVisitor = data?.find(v => v.session_id === selectedVisitor.session_id) - if (updatedVisitor) { - // Don't overwrite the selectedVisitor state directly to avoid flickering details - // But we could update "last seen" indicators if we wanted - } - } - } catch (e) { - // Silent fail - } - }, 5000) - return () => clearInterval(interval) - }, [siteId, selectedVisitor]) + if (visitors.length > 0 && !selectedVisitor) { + handleSelectVisitor(visitors[0]) + } + }, [visitors]) // eslint-disable-line react-hooks/exhaustive-deps const handleSelectVisitor = async (visitor: Visitor) => { setSelectedVisitor(visitor) diff --git a/lib/hooks/useRealtimeSSE.ts b/lib/hooks/useRealtimeSSE.ts new file mode 100644 index 0000000..63e3b63 --- /dev/null +++ b/lib/hooks/useRealtimeSSE.ts @@ -0,0 +1,53 @@ +// * SSE hook for real-time visitor streaming. +// * Replaces 5-second polling with a persistent EventSource connection. +// * The backend broadcasts one DB query per site to all connected clients, +// * so 1,000 users on the same site share a single query instead of each +// * triggering their own. + +import { useEffect, useRef, useState, useCallback } from 'react' +import { API_URL } from '@/lib/api/client' +import type { Visitor } from '@/lib/api/realtime' + +interface UseRealtimeSSEReturn { + visitors: Visitor[] + connected: boolean +} + +export function useRealtimeSSE(siteId: string): UseRealtimeSSEReturn { + const [visitors, setVisitors] = useState([]) + const [connected, setConnected] = useState(false) + const esRef = useRef(null) + + // Stable callback so we don't recreate EventSource on every render + const handleMessage = useCallback((event: MessageEvent) => { + try { + const data = JSON.parse(event.data) + setVisitors(data.visitors || []) + } catch { + // Ignore malformed messages + } + }, []) + + useEffect(() => { + if (!siteId) return + + const url = `${API_URL}/api/v1/sites/${siteId}/realtime/stream` + const es = new EventSource(url, { withCredentials: true }) + esRef.current = es + + es.onopen = () => setConnected(true) + es.onmessage = handleMessage + es.onerror = () => { + setConnected(false) + // EventSource auto-reconnects with exponential backoff + } + + return () => { + es.close() + esRef.current = null + setConnected(false) + } + }, [siteId, handleMessage]) + + return { visitors, connected } +} From beee87bd2e11c2024860bd574407744cc08f3db7 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 10 Mar 2026 18:45:52 +0100 Subject: [PATCH 067/109] docs: add query timeout improvement to changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b01664..2c9b829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Even faster dashboard loading.** Your dashboard now fetches all its data — pages, locations, devices, referrers, performance, and goals — in a single request instead of seven separate ones. This means the entire dashboard appears at once rather than sections loading one by one, and puts much less strain on the server when many people are viewing their analytics at the same time. - **Smoother real-time updates.** The real-time visitors page now streams updates instantly from the server instead of checking for new data every few seconds. Visitors appear and disappear in real-time with no delay, and the page uses far fewer server resources — especially when many people are watching their live traffic at the same time. +- **More reliable under heavy load.** Database queries now have automatic time limits so a single slow query can never lock up the system. If your dashboard or stats take too long to load, the request is gracefully cancelled instead of hanging forever — keeping everything responsive even during traffic spikes. ### Added From 835c284a6b16ad66d58bb91dbf8f05c86fe5ee91 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 10 Mar 2026 20:13:09 +0100 Subject: [PATCH 068/109] docs: add smarter caching improvement to changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c9b829..30d6ef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Even faster dashboard loading.** Your dashboard now fetches all its data — pages, locations, devices, referrers, performance, and goals — in a single request instead of seven separate ones. This means the entire dashboard appears at once rather than sections loading one by one, and puts much less strain on the server when many people are viewing their analytics at the same time. - **Smoother real-time updates.** The real-time visitors page now streams updates instantly from the server instead of checking for new data every few seconds. Visitors appear and disappear in real-time with no delay, and the page uses far fewer server resources — especially when many people are watching their live traffic at the same time. - **More reliable under heavy load.** Database queries now have automatic time limits so a single slow query can never lock up the system. If your dashboard or stats take too long to load, the request is gracefully cancelled instead of hanging forever — keeping everything responsive even during traffic spikes. +- **Smarter caching for dashboard data.** Your dashboard stats are now cached for longer and shared more efficiently between requests. When the cache refreshes, only one request does the work while others wait for the result — so your dashboard loads consistently fast even when lots of people are viewing their analytics at the same time. ### Added From 848bde237fe4fcbad92a89d335f162c1bcbba1de Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 10 Mar 2026 20:25:57 +0100 Subject: [PATCH 069/109] docs: add faster entry/exit page stats to changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30d6ef2..60e568b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Smoother real-time updates.** The real-time visitors page now streams updates instantly from the server instead of checking for new data every few seconds. Visitors appear and disappear in real-time with no delay, and the page uses far fewer server resources — especially when many people are watching their live traffic at the same time. - **More reliable under heavy load.** Database queries now have automatic time limits so a single slow query can never lock up the system. If your dashboard or stats take too long to load, the request is gracefully cancelled instead of hanging forever — keeping everything responsive even during traffic spikes. - **Smarter caching for dashboard data.** Your dashboard stats are now cached for longer and shared more efficiently between requests. When the cache refreshes, only one request does the work while others wait for the result — so your dashboard loads consistently fast even when lots of people are viewing their analytics at the same time. +- **Faster filtered views.** When you filter your dashboard by country, browser, page, or any other dimension, the results are now cached so repeat views load instantly. If multiple people apply the same filter, only one lookup runs and the result is shared — making filtered views much snappier under heavy use. +- **Faster entry and exit page stats.** The queries that figure out which pages visitors land on and leave from have been rewritten to be much more efficient. Instead of sorting through every single event, they now look up just the first and last page per visit — so your Entry Pages and Exit Pages panels load noticeably faster, especially on high-traffic sites. ### Added From f10b903a808cad576136eb4d739e152ab911dcdd Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 10 Mar 2026 20:45:49 +0100 Subject: [PATCH 070/109] perf: add export loading state and virtual scrolling for large lists Export modal now shows a loading indicator and doesn't freeze the UI. Large list modals use virtual scrolling for smooth performance. --- components/dashboard/Campaigns.tsx | 66 +-- components/dashboard/ContentStats.tsx | 54 ++- components/dashboard/ExportModal.tsx | 583 +++++++++++++------------- components/dashboard/Locations.tsx | 68 +-- components/dashboard/TechSpecs.tsx | 56 +-- components/dashboard/TopReferrers.tsx | 50 ++- components/dashboard/VirtualList.tsx | 53 +++ package-lock.json | 28 ++ package.json | 1 + 9 files changed, 546 insertions(+), 413 deletions(-) create mode 100644 components/dashboard/VirtualList.tsx diff --git a/components/dashboard/Campaigns.tsx b/components/dashboard/Campaigns.tsx index 7e4b35c..397f5b9 100644 --- a/components/dashboard/Campaigns.tsx +++ b/components/dashboard/Campaigns.tsx @@ -7,6 +7,7 @@ import Image from 'next/image' import { formatNumber } from '@ciphera-net/ui' import { Modal, ArrowRightIcon } from '@ciphera-net/ui' import { ListSkeleton } from '@/components/skeletons' +import VirtualList from './VirtualList' import { getCampaigns, CampaignStat } from '@/lib/api/stats' import { getReferrerFavicon, getReferrerIcon, getReferrerDisplayName } from '@/lib/utils/icons' import { Megaphone, FrameCornersIcon } from '@phosphor-icons/react' @@ -225,7 +226,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp className="w-full px-3 py-2 mb-3 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 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50" />
-
+
{isLoadingFull ? (
@@ -246,38 +247,43 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp Export CSV
- {filteredCampaigns.map((item) => ( -
{ if (onFilter) { onFilter({ dimension: 'utm_source', operator: 'is', values: [item.source] }); setIsModalOpen(false) } }} - className={`flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} - > -
- {renderSourceIcon(item.source)} -
-
- {getReferrerDisplayName(item.source)} -
-
- {item.medium || '—'} - · - {item.campaign || '—'} + ( +
{ if (onFilter) { onFilter({ dimension: 'utm_source', operator: 'is', values: [item.source] }); setIsModalOpen(false) } }} + className={`flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} + > +
+ {renderSourceIcon(item.source)} +
+
+ {getReferrerDisplayName(item.source)} +
+
+ {item.medium || '—'} + · + {item.campaign || '—'} +
+
+ + {modalTotal > 0 ? `${Math.round((item.visitors / modalTotal) * 100)}%` : ''} + + + {formatNumber(item.visitors)} + + + {formatNumber(item.pageviews)} pv + +
-
- - {modalTotal > 0 ? `${Math.round((item.visitors / modalTotal) * 100)}%` : ''} - - - {formatNumber(item.visitors)} - - - {formatNumber(item.pageviews)} pv - -
-
- ))} + )} + /> ) })()} diff --git a/components/dashboard/ContentStats.tsx b/components/dashboard/ContentStats.tsx index ab8a029..4ed61aa 100644 --- a/components/dashboard/ContentStats.tsx +++ b/components/dashboard/ContentStats.tsx @@ -9,6 +9,7 @@ import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/sta import { FrameCornersIcon } from '@phosphor-icons/react' import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui' import { ListSkeleton } from '@/components/skeletons' +import VirtualList from './VirtualList' import { type DimensionFilter } from '@/lib/filters' interface ContentStatsProps { @@ -209,7 +210,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain, className="w-full px-3 py-2 mb-3 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 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50" />
-
+
{isLoadingFull ? (
@@ -217,28 +218,35 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain, ) : (() => { const modalData = (fullData.length > 0 ? fullData : data).filter(p => !modalSearch || p.path.toLowerCase().includes(modalSearch.toLowerCase())) const modalTotal = modalData.reduce((sum, p) => sum + p.pageviews, 0) - return modalData.map((page) => { - const canFilter = onFilter && page.path - return ( -
{ if (canFilter) { onFilter({ dimension: 'page', operator: 'is', values: [page.path] }); setIsModalOpen(false) } }} - className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} - > -
- {page.path} -
-
- - {modalTotal > 0 ? `${Math.round((page.pageviews / modalTotal) * 100)}%` : ''} - - - {formatNumber(page.pageviews)} - -
-
- ) - }) + return ( + { + const canFilter = onFilter && page.path + return ( +
{ if (canFilter) { onFilter({ dimension: 'page', operator: 'is', values: [page.path] }); setIsModalOpen(false) } }} + className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} + > +
+ {page.path} +
+
+ + {modalTotal > 0 ? `${Math.round((page.pageviews / modalTotal) * 100)}%` : ''} + + + {formatNumber(page.pageviews)} + +
+
+ ) + }} + /> + ) })()}
diff --git a/components/dashboard/ExportModal.tsx b/components/dashboard/ExportModal.tsx index 8a347ac..184c2b1 100644 --- a/components/dashboard/ExportModal.tsx +++ b/components/dashboard/ExportModal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useCallback } from 'react' import { Modal, Button, Checkbox, Input, Select } from '@ciphera-net/ui' import * as XLSX from 'xlsx' import jsPDF from 'jspdf' @@ -49,6 +49,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to const [format, setFormat] = useState('csv') const [filename, setFilename] = useState(`pulse_export_${new Date().toISOString().split('T')[0]}`) const [includeHeader, setIncludeHeader] = useState(true) + const [isExporting, setIsExporting] = useState(false) const [selectedFields, setSelectedFields] = useState>({ date: true, pageviews: true, @@ -61,300 +62,312 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to setSelectedFields((prev) => ({ ...prev, [field]: checked })) } - const handleExport = async () => { - // Filter fields - const fields = (Object.keys(selectedFields) as Array).filter((k) => selectedFields[k]) - - // Prepare data - const exportData = data.map((item) => { - const filteredItem: Record = {} - fields.forEach((field) => { - filteredItem[field] = item[field] - }) - return filteredItem - }) + const handleExport = () => { + setIsExporting(true) + // Let the browser paint the loading state before starting heavy work + requestAnimationFrame(() => { + setTimeout(async () => { + try { + // Filter fields + const fields = (Object.keys(selectedFields) as Array).filter((k) => selectedFields[k]) - let content = '' - let mimeType = '' - let extension = '' + // Prepare data + const exportData = data.map((item) => { + const filteredItem: Record = {} + fields.forEach((field) => { + filteredItem[field] = item[field] + }) + return filteredItem + }) - if (format === 'csv') { - const header = fields.join(',') - const rows = exportData.map((row) => - fields.map((field) => { - const val = row[field] - if (field === 'date' && typeof val === 'string') { - return new Date(val).toISOString() + let content = '' + let mimeType = '' + let extension = '' + + if (format === 'csv') { + const header = fields.join(',') + const rows = exportData.map((row) => + fields.map((field) => { + const val = row[field] + if (field === 'date' && typeof val === 'string') { + return new Date(val).toISOString() + } + return val + }).join(',') + ) + content = (includeHeader ? header + '\n' : '') + rows.join('\n') + mimeType = 'text/csv;charset=utf-8;' + extension = 'csv' + } else if (format === 'xlsx') { + const ws = XLSX.utils.json_to_sheet(exportData) + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, ws, 'Data') + if (campaigns && campaigns.length > 0) { + const campaignsSheet = XLSX.utils.json_to_sheet( + campaigns.map(c => ({ + Source: getReferrerDisplayName(c.source), + Medium: c.medium || '—', + Campaign: c.campaign || '—', + Visitors: c.visitors, + Pageviews: c.pageviews, + })) + ) + XLSX.utils.book_append_sheet(wb, campaignsSheet, 'Campaigns') } - return val - }).join(',') - ) - content = (includeHeader ? header + '\n' : '') + rows.join('\n') - mimeType = 'text/csv;charset=utf-8;' - extension = 'csv' - } else if (format === 'xlsx') { - const ws = XLSX.utils.json_to_sheet(exportData) - const wb = XLSX.utils.book_new() - XLSX.utils.book_append_sheet(wb, ws, 'Data') - if (campaigns && campaigns.length > 0) { - const campaignsSheet = XLSX.utils.json_to_sheet( - campaigns.map(c => ({ - Source: getReferrerDisplayName(c.source), - Medium: c.medium || '—', - Campaign: c.campaign || '—', - Visitors: c.visitors, - Pageviews: c.pageviews, - })) - ) - XLSX.utils.book_append_sheet(wb, campaignsSheet, 'Campaigns') - } - const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }) - const blob = new Blob([wbout], { type: 'application/octet-stream' }) - - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.setAttribute('href', url) - link.setAttribute('download', `${filename || 'export'}.${extension || 'xlsx'}`) - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - onClose() - return - } else if (format === 'pdf') { - const doc = new jsPDF() - - // Header Section - try { - // Logo - const logoData = await loadImage('/pulse_icon_no_margins.png') - doc.addImage(logoData, 'PNG', 14, 12, 12, 12) // x, y, w, h - - // Title - doc.setFontSize(22) - doc.setTextColor(249, 115, 22) // Brand Orange #F97316 - doc.text('Pulse', 32, 20) - - doc.setFontSize(12) - doc.setTextColor(100, 100, 100) - doc.text('Analytics Export', 32, 25) - } catch (e) { - // Fallback if logo fails - doc.setFontSize(22) - doc.setTextColor(249, 115, 22) - doc.text('Pulse Analytics', 14, 20) - } + const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }) + const blob = new Blob([wbout], { type: 'application/octet-stream' }) - // Metadata (Top Right) - doc.setFontSize(9) - doc.setTextColor(150, 150, 150) - const generatedDate = new Date().toLocaleDateString() - const dateRange = data.length > 0 - ? `${new Date(data[0].date).toLocaleDateString()} - ${new Date(data[data.length - 1].date).toLocaleDateString()}` - : generatedDate - - const pageWidth = doc.internal.pageSize.width - doc.text(`Generated: ${generatedDate}`, pageWidth - 14, 18, { align: 'right' }) - doc.text(`Range: ${dateRange}`, pageWidth - 14, 23, { align: 'right' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.setAttribute('href', url) + link.setAttribute('download', `${filename || 'export'}.${extension || 'xlsx'}`) + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + onClose() + return + } else if (format === 'pdf') { + const doc = new jsPDF() - let startY = 35 + // Header Section + try { + // Logo + const logoData = await loadImage('/pulse_icon_no_margins.png') + doc.addImage(logoData, 'PNG', 14, 12, 12, 12) // x, y, w, h - // Summary Section - if (stats) { - const summaryY = 35 - const cardWidth = (pageWidth - 28 - 15) / 4 // 4 cards with 5mm gap - const cardHeight = 20 - - const drawCard = (x: number, label: string, value: string) => { - doc.setFillColor(255, 247, 237) // Very light orange - doc.setDrawColor(254, 215, 170) // Light orange border - doc.roundedRect(x, summaryY, cardWidth, cardHeight, 2, 2, 'FD') - - doc.setFontSize(8) + // Title + doc.setFontSize(22) + doc.setTextColor(249, 115, 22) // Brand Orange #F97316 + doc.text('Pulse', 32, 20) + + doc.setFontSize(12) + doc.setTextColor(100, 100, 100) + doc.text('Analytics Export', 32, 25) + } catch (e) { + // Fallback if logo fails + doc.setFontSize(22) + doc.setTextColor(249, 115, 22) + doc.text('Pulse Analytics', 14, 20) + } + + // Metadata (Top Right) + doc.setFontSize(9) doc.setTextColor(150, 150, 150) - doc.text(label, x + 3, summaryY + 6) - - doc.setFontSize(12) - doc.setTextColor(23, 23, 23) // Neutral 900 - doc.setFont('helvetica', 'bold') - doc.text(value, x + 3, summaryY + 14) - doc.setFont('helvetica', 'normal') - } + const generatedDate = new Date().toLocaleDateString() + const dateRange = data.length > 0 + ? `${new Date(data[0].date).toLocaleDateString()} - ${new Date(data[data.length - 1].date).toLocaleDateString()}` + : generatedDate - drawCard(14, 'Unique Visitors', formatNumber(stats.visitors)) - drawCard(14 + cardWidth + 5, 'Total Pageviews', formatNumber(stats.pageviews)) - drawCard(14 + (cardWidth + 5) * 2, 'Bounce Rate', `${Math.round(stats.bounce_rate)}%`) - drawCard(14 + (cardWidth + 5) * 3, 'Avg Duration', formatDuration(stats.avg_duration)) - - startY = 65 // Move table down - } + const pageWidth = doc.internal.pageSize.width + doc.text(`Generated: ${generatedDate}`, pageWidth - 14, 18, { align: 'right' }) + doc.text(`Range: ${dateRange}`, pageWidth - 14, 23, { align: 'right' }) - // Check if data is hourly (same date for multiple rows) - const isHourly = data.length > 1 && data[0].date.split('T')[0] === data[1].date.split('T')[0] + let startY = 35 - const tableData = exportData.map(row => - fields.map(field => { - const val = row[field] - if (field === 'date' && typeof val === 'string') { - const date = new Date(val) - return isHourly - ? date.toLocaleString('en-US', { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }) - : date.toLocaleDateString() + // Summary Section + if (stats) { + const summaryY = 35 + const cardWidth = (pageWidth - 28 - 15) / 4 // 4 cards with 5mm gap + const cardHeight = 20 + + const drawCard = (x: number, label: string, value: string) => { + doc.setFillColor(255, 247, 237) // Very light orange + doc.setDrawColor(254, 215, 170) // Light orange border + doc.roundedRect(x, summaryY, cardWidth, cardHeight, 2, 2, 'FD') + + doc.setFontSize(8) + doc.setTextColor(150, 150, 150) + doc.text(label, x + 3, summaryY + 6) + + doc.setFontSize(12) + doc.setTextColor(23, 23, 23) // Neutral 900 + doc.setFont('helvetica', 'bold') + doc.text(value, x + 3, summaryY + 14) + doc.setFont('helvetica', 'normal') + } + + drawCard(14, 'Unique Visitors', formatNumber(stats.visitors)) + drawCard(14 + cardWidth + 5, 'Total Pageviews', formatNumber(stats.pageviews)) + drawCard(14 + (cardWidth + 5) * 2, 'Bounce Rate', `${Math.round(stats.bounce_rate)}%`) + drawCard(14 + (cardWidth + 5) * 3, 'Avg Duration', formatDuration(stats.avg_duration)) + + startY = 65 // Move table down + } + + // Check if data is hourly (same date for multiple rows) + const isHourly = data.length > 1 && data[0].date.split('T')[0] === data[1].date.split('T')[0] + + const tableData = exportData.map(row => + fields.map(field => { + const val = row[field] + if (field === 'date' && typeof val === 'string') { + const date = new Date(val) + return isHourly + ? date.toLocaleString('en-US', { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }) + : date.toLocaleDateString() + } + if (typeof val === 'number') { + if (field === 'bounce_rate') return `${Math.round(val)}%` + if (field === 'avg_duration') return formatDuration(val) + if (field === 'pageviews' || field === 'visitors') return formatNumber(val) + } + return val ?? '' + }) + ) + + autoTable(doc, { + startY: startY, + head: [fields.map(f => f.charAt(0).toUpperCase() + f.slice(1).replace('_', ' '))], + body: tableData as (string | number)[][], + styles: { + font: 'helvetica', + fontSize: 9, + cellPadding: 4, + lineColor: [229, 231, 235], // Neutral 200 + lineWidth: 0.1, + }, + headStyles: { + fillColor: [249, 115, 22], // Brand Orange + textColor: [255, 255, 255], + fontStyle: 'bold', + halign: 'left' + }, + columnStyles: { + 0: { halign: 'left' }, // Date + 1: { halign: 'right' }, // Pageviews + 2: { halign: 'right' }, // Visitors + 3: { halign: 'right' }, // Bounce Rate + 4: { halign: 'right' }, // Avg Duration + }, + alternateRowStyles: { + fillColor: [255, 250, 245], // Very very light orange + }, + didDrawPage: (data) => { + // Footer + const pageSize = doc.internal.pageSize + const pageHeight = pageSize.height ? pageSize.height : pageSize.getHeight() + doc.setFontSize(8) + doc.setTextColor(150, 150, 150) + doc.text('Powered by Ciphera', 14, pageHeight - 10) + + const str = 'Page ' + doc.getNumberOfPages() + doc.text(str, pageSize.width - 14, pageHeight - 10, { align: 'right' }) + } + }) + + let finalY = doc.lastAutoTable.finalY + 10 + + // Top Pages Table + if (topPages && topPages.length > 0) { + // Check if we need a new page + if (finalY + 40 > doc.internal.pageSize.height) { + doc.addPage() + finalY = 20 + } + + doc.setFontSize(14) + doc.setTextColor(23, 23, 23) + doc.text('Top Pages', 14, finalY) + finalY += 5 + + const pagesData = topPages.slice(0, 10).map(p => [p.path, formatNumber(p.pageviews)]) + + autoTable(doc, { + startY: finalY, + head: [['Path', 'Pageviews']], + body: pagesData, + styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 }, + headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' }, + columnStyles: { 1: { halign: 'right' } }, + alternateRowStyles: { fillColor: [255, 250, 245] }, + }) + + finalY = doc.lastAutoTable.finalY + 10 + } + + // Top Referrers Table + if (topReferrers && topReferrers.length > 0) { + // Check if we need a new page + if (finalY + 40 > doc.internal.pageSize.height) { + doc.addPage() + finalY = 20 + } + + doc.setFontSize(14) + doc.setTextColor(23, 23, 23) + doc.text('Top Referrers', 14, finalY) + finalY += 5 + + const mergedReferrers = mergeReferrersByDisplayName(topReferrers) + const referrersData = mergedReferrers.slice(0, 10).map(r => [getReferrerDisplayName(r.referrer), formatNumber(r.pageviews)]) + + autoTable(doc, { + startY: finalY, + head: [['Referrer', 'Pageviews']], + body: referrersData, + styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 }, + headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' }, + columnStyles: { 1: { halign: 'right' } }, + alternateRowStyles: { fillColor: [255, 250, 245] }, + }) + + finalY = doc.lastAutoTable.finalY + 10 + } + + // Campaigns Table + if (campaigns && campaigns.length > 0) { + if (finalY + 40 > doc.internal.pageSize.height) { + doc.addPage() + finalY = 20 + } + doc.setFontSize(14) + doc.setTextColor(23, 23, 23) + doc.text('Campaigns', 14, finalY) + finalY += 5 + const campaignsData = campaigns.slice(0, 10).map(c => [ + getReferrerDisplayName(c.source), + c.medium || '—', + c.campaign || '—', + formatNumber(c.visitors), + formatNumber(c.pageviews), + ]) + autoTable(doc, { + startY: finalY, + head: [['Source', 'Medium', 'Campaign', 'Visitors', 'Pageviews']], + body: campaignsData, + styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 }, + headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' }, + columnStyles: { 3: { halign: 'right' }, 4: { halign: 'right' } }, + alternateRowStyles: { fillColor: [255, 250, 245] }, + }) + } + + doc.save(`${filename || 'export'}.pdf`) + onClose() + return + } else { + content = JSON.stringify(exportData, null, 2) + mimeType = 'application/json;charset=utf-8;' + extension = 'json' } - if (typeof val === 'number') { - if (field === 'bounce_rate') return `${Math.round(val)}%` - if (field === 'avg_duration') return formatDuration(val) - if (field === 'pageviews' || field === 'visitors') return formatNumber(val) - } - return val ?? '' - }) - ) - autoTable(doc, { - startY: startY, - head: [fields.map(f => f.charAt(0).toUpperCase() + f.slice(1).replace('_', ' '))], - body: tableData as (string | number)[][], - styles: { - font: 'helvetica', - fontSize: 9, - cellPadding: 4, - lineColor: [229, 231, 235], // Neutral 200 - lineWidth: 0.1, - }, - headStyles: { - fillColor: [249, 115, 22], // Brand Orange - textColor: [255, 255, 255], - fontStyle: 'bold', - halign: 'left' - }, - columnStyles: { - 0: { halign: 'left' }, // Date - 1: { halign: 'right' }, // Pageviews - 2: { halign: 'right' }, // Visitors - 3: { halign: 'right' }, // Bounce Rate - 4: { halign: 'right' }, // Avg Duration - }, - alternateRowStyles: { - fillColor: [255, 250, 245], // Very very light orange - }, - didDrawPage: (data) => { - // Footer - const pageSize = doc.internal.pageSize - const pageHeight = pageSize.height ? pageSize.height : pageSize.getHeight() - doc.setFontSize(8) - doc.setTextColor(150, 150, 150) - doc.text('Powered by Ciphera', 14, pageHeight - 10) - - const str = 'Page ' + doc.getNumberOfPages() - doc.text(str, pageSize.width - 14, pageHeight - 10, { align: 'right' }) + const blob = new Blob([content], { type: mimeType }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.setAttribute('href', url) + link.setAttribute('download', `${filename || 'export'}.${extension}`) + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + + onClose() + } catch (e) { + console.error('Export failed:', e) + } finally { + setIsExporting(false) } - }) - - let finalY = doc.lastAutoTable.finalY + 10 - - // Top Pages Table - if (topPages && topPages.length > 0) { - // Check if we need a new page - if (finalY + 40 > doc.internal.pageSize.height) { - doc.addPage() - finalY = 20 - } - - doc.setFontSize(14) - doc.setTextColor(23, 23, 23) - doc.text('Top Pages', 14, finalY) - finalY += 5 - - const pagesData = topPages.slice(0, 10).map(p => [p.path, formatNumber(p.pageviews)]) - - autoTable(doc, { - startY: finalY, - head: [['Path', 'Pageviews']], - body: pagesData, - styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 }, - headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' }, - columnStyles: { 1: { halign: 'right' } }, - alternateRowStyles: { fillColor: [255, 250, 245] }, - }) - - finalY = doc.lastAutoTable.finalY + 10 - } - - // Top Referrers Table - if (topReferrers && topReferrers.length > 0) { - // Check if we need a new page - if (finalY + 40 > doc.internal.pageSize.height) { - doc.addPage() - finalY = 20 - } - - doc.setFontSize(14) - doc.setTextColor(23, 23, 23) - doc.text('Top Referrers', 14, finalY) - finalY += 5 - - const mergedReferrers = mergeReferrersByDisplayName(topReferrers) - const referrersData = mergedReferrers.slice(0, 10).map(r => [getReferrerDisplayName(r.referrer), formatNumber(r.pageviews)]) - - autoTable(doc, { - startY: finalY, - head: [['Referrer', 'Pageviews']], - body: referrersData, - styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 }, - headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' }, - columnStyles: { 1: { halign: 'right' } }, - alternateRowStyles: { fillColor: [255, 250, 245] }, - }) - - finalY = doc.lastAutoTable.finalY + 10 - } - - // Campaigns Table - if (campaigns && campaigns.length > 0) { - if (finalY + 40 > doc.internal.pageSize.height) { - doc.addPage() - finalY = 20 - } - doc.setFontSize(14) - doc.setTextColor(23, 23, 23) - doc.text('Campaigns', 14, finalY) - finalY += 5 - const campaignsData = campaigns.slice(0, 10).map(c => [ - getReferrerDisplayName(c.source), - c.medium || '—', - c.campaign || '—', - formatNumber(c.visitors), - formatNumber(c.pageviews), - ]) - autoTable(doc, { - startY: finalY, - head: [['Source', 'Medium', 'Campaign', 'Visitors', 'Pageviews']], - body: campaignsData, - styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 }, - headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' }, - columnStyles: { 3: { halign: 'right' }, 4: { halign: 'right' } }, - alternateRowStyles: { fillColor: [255, 250, 245] }, - }) - } - - doc.save(`${filename || 'export'}.pdf`) - onClose() - return - } else { - content = JSON.stringify(exportData, null, 2) - mimeType = 'application/json;charset=utf-8;' - extension = 'json' - } - - const blob = new Blob([content], { type: mimeType }) - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.setAttribute('href', url) - link.setAttribute('download', `${filename || 'export'}.${extension}`) - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - - onClose() + }, 0) + }) } return ( @@ -440,11 +453,11 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to {/* Actions */}
- -
diff --git a/components/dashboard/Locations.tsx b/components/dashboard/Locations.tsx index f9d3406..68fb707 100644 --- a/components/dashboard/Locations.tsx +++ b/components/dashboard/Locations.tsx @@ -13,6 +13,7 @@ const DottedMap = dynamic(() => import('./DottedMap'), { ssr: false }) const Globe = dynamic(() => import('./Globe'), { ssr: false }) import { Modal, GlobeIcon } from '@ciphera-net/ui' import { ListSkeleton } from '@/components/skeletons' +import VirtualList from './VirtualList' import { ShieldCheck, Detective, Broadcast, FrameCornersIcon } from '@phosphor-icons/react' import { getCountries, getCities, getRegions } from '@/lib/api/stats' import { type DimensionFilter } from '@/lib/filters' @@ -334,7 +335,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' className="w-full px-3 py-2 mb-3 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 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50" />
-
+
{isLoadingFull ? (
@@ -347,35 +348,42 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' return label.toLowerCase().includes(search) }) const modalTotal = modalData.reduce((sum, item) => sum + item.pageviews, 0) - return modalData.map((item) => { - const dim = TAB_TO_DIMENSION[activeTab] - const filterValue = activeTab === 'countries' ? item.country : activeTab === 'regions' ? item.region : item.city - const canFilter = onFilter && dim && filterValue - return ( -
{ if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [filterValue!] }); setIsModalOpen(false) } }} - className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} - > -
- {getFlagComponent(item.country ?? '')} - - {activeTab === 'countries' ? getCountryName(item.country ?? '') : - activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') : - getCityName(item.city ?? '')} - -
-
- - {modalTotal > 0 ? `${Math.round((item.pageviews / modalTotal) * 100)}%` : ''} - - - {formatNumber(item.pageviews)} - -
-
- ) - }) + return ( + { + const dim = TAB_TO_DIMENSION[activeTab] + const filterValue = activeTab === 'countries' ? item.country : activeTab === 'regions' ? item.region : item.city + const canFilter = onFilter && dim && filterValue + return ( +
{ if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [filterValue!] }); setIsModalOpen(false) } }} + className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} + > +
+ {getFlagComponent(item.country ?? '')} + + {activeTab === 'countries' ? getCountryName(item.country ?? '') : + activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') : + getCityName(item.city ?? '')} + +
+
+ + {modalTotal > 0 ? `${Math.round((item.pageviews / modalTotal) * 100)}%` : ''} + + + {formatNumber(item.pageviews)} + +
+
+ ) + }} + /> + ) })()}
diff --git a/components/dashboard/TechSpecs.tsx b/components/dashboard/TechSpecs.tsx index 59cdec4..cd2114f 100644 --- a/components/dashboard/TechSpecs.tsx +++ b/components/dashboard/TechSpecs.tsx @@ -9,6 +9,7 @@ import { getBrowserIcon, getOSIcon, getDeviceIcon } from '@/lib/utils/icons' import { Monitor, FrameCornersIcon } from '@phosphor-icons/react' import { Modal, GridIcon } from '@ciphera-net/ui' import { ListSkeleton } from '@/components/skeletons' +import VirtualList from './VirtualList' import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats' import { type DimensionFilter } from '@/lib/filters' @@ -235,7 +236,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co className="w-full px-3 py-2 mb-3 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 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50" />
-
+
{isLoadingFull ? (
@@ -244,29 +245,36 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co const modalData = (fullData.length > 0 ? fullData : data).filter(item => !modalSearch || item.name.toLowerCase().includes(modalSearch.toLowerCase())) const modalTotal = modalData.reduce((sum, item) => sum + item.pageviews, 0) const dim = TAB_TO_DIMENSION[activeTab] - return modalData.map((item) => { - const canFilter = onFilter && dim - return ( -
{ if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [item.name] }); setIsModalOpen(false) } }} - className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} - > -
- {item.icon && {item.icon}} - {capitalize(item.name)} -
-
- - {modalTotal > 0 ? `${Math.round((item.pageviews / modalTotal) * 100)}%` : ''} - - - {formatNumber(item.pageviews)} - -
-
- ) - }) + return ( + { + const canFilter = onFilter && dim + return ( +
{ if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [item.name] }); setIsModalOpen(false) } }} + className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} + > +
+ {item.icon && {item.icon}} + {capitalize(item.name)} +
+
+ + {modalTotal > 0 ? `${Math.round((item.pageviews / modalTotal) * 100)}%` : ''} + + + {formatNumber(item.pageviews)} + +
+
+ ) + }} + /> + ) })()}
diff --git a/components/dashboard/TopReferrers.tsx b/components/dashboard/TopReferrers.tsx index 83b172b..78e4fdd 100644 --- a/components/dashboard/TopReferrers.tsx +++ b/components/dashboard/TopReferrers.tsx @@ -8,6 +8,7 @@ import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeRefer import { FrameCornersIcon } from '@phosphor-icons/react' import { Modal, GlobeIcon } from '@ciphera-net/ui' import { ListSkeleton } from '@/components/skeletons' +import VirtualList from './VirtualList' import { getTopReferrers, TopReferrer } from '@/lib/api/stats' import { type DimensionFilter } from '@/lib/filters' @@ -165,7 +166,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI className="w-full px-3 py-2 mb-3 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 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50" />
-
+
{isLoadingFull ? (
@@ -173,26 +174,33 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI ) : (() => { const modalData = mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).filter(r => !modalSearch || getReferrerDisplayName(r.referrer).toLowerCase().includes(modalSearch.toLowerCase())) const modalTotal = modalData.reduce((sum, r) => sum + r.pageviews, 0) - return modalData.map((ref) => ( -
{ if (onFilter) { onFilter({ dimension: 'referrer', operator: 'is', values: [ref.referrer] }); setIsModalOpen(false) } }} - className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} - > -
- {renderReferrerIcon(ref.referrer)} - {getReferrerDisplayName(ref.referrer)} -
-
- - {modalTotal > 0 ? `${Math.round((ref.pageviews / modalTotal) * 100)}%` : ''} - - - {formatNumber(ref.pageviews)} - -
-
- )) + return ( + ( +
{ if (onFilter) { onFilter({ dimension: 'referrer', operator: 'is', values: [ref.referrer] }); setIsModalOpen(false) } }} + className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} + > +
+ {renderReferrerIcon(ref.referrer)} + {getReferrerDisplayName(ref.referrer)} +
+
+ + {modalTotal > 0 ? `${Math.round((ref.pageviews / modalTotal) * 100)}%` : ''} + + + {formatNumber(ref.pageviews)} + +
+
+ )} + /> + ) })()}
diff --git a/components/dashboard/VirtualList.tsx b/components/dashboard/VirtualList.tsx new file mode 100644 index 0000000..71f8ec3 --- /dev/null +++ b/components/dashboard/VirtualList.tsx @@ -0,0 +1,53 @@ +'use client' + +import { useRef } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' + +interface VirtualListProps { + items: T[] + estimateSize: number + className?: string + renderItem: (item: T, index: number) => React.ReactNode +} + +export default function VirtualList({ items, estimateSize, className, renderItem }: VirtualListProps) { + const parentRef = useRef(null) + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => estimateSize, + overscan: 10, + }) + + // For small lists (< 50 items), render directly without virtualization + if (items.length < 50) { + return ( +
+ {items.map((item, index) => renderItem(item, index))} +
+ ) + } + + return ( +
+
+ {virtualizer.getVirtualItems().map((virtualRow) => ( +
+ {renderItem(items[virtualRow.index], virtualRow.index)} +
+ ))} +
+
+ ) +} diff --git a/package-lock.json b/package-lock.json index 89e22e0..4d30b79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@simplewebauthn/browser": "^13.2.2", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", + "@tanstack/react-virtual": "^3.13.21", "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "cobe": "^0.6.5", @@ -5405,6 +5406,33 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.21.tgz", + "integrity": "sha512-SYXFrmrbPgXBvf+HsOsKhFgqSe4M6B29VHOsX9Jih9TlNkNkDWx0hWMiMLUghMEzyUz772ndzdEeCEBx+3GIZw==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.21" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.21", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.21.tgz", + "integrity": "sha512-ww+fmLHyCbPSf7JNbWZP3g7wl6SdNo3ah5Aiw+0e9FDErkVHLKprYUrwTm7dF646FtEkN/KkAKPYezxpmvOjxw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index 3b4a9fb..ad5f188 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@simplewebauthn/browser": "^13.2.2", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", + "@tanstack/react-virtual": "^3.13.21", "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "cobe": "^0.6.5", From 502f4952fc8aeefb7e03c961352e9e1ae573024e Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 10 Mar 2026 20:57:55 +0100 Subject: [PATCH 071/109] perf: lazy-load globe/map and update changelog Globe and DottedMap now only render when the Locations section enters the viewport via IntersectionObserver. Added changelog entries for rate limit fallback, buffer improvements, and lazy loading. --- CHANGELOG.md | 9 +++++++++ components/dashboard/Locations.tsx | 22 ++++++++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60e568b..0c487ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Improved +- **More resilient under Redis outages.** If the caching layer goes down temporarily, Pulse now continues enforcing rate limits using an in-memory fallback instead of letting all traffic through unchecked. This prevents one infrastructure hiccup from snowballing into a bigger problem. +- **Better handling of traffic bursts.** The system can now absorb 5x larger spikes of incoming events before applying backpressure. When events are dropped during extreme bursts, the system now tracks and logs exactly how many — so we can detect and respond to sustained overload before it affects your data. +- **Faster map and globe loading.** The interactive 3D globe and dotted map in the Locations panel now only load when you scroll down to them, instead of rendering immediately on page load. This makes the initial dashboard load faster and saves battery on mobile devices. - **Even faster dashboard loading.** Your dashboard now fetches all its data — pages, locations, devices, referrers, performance, and goals — in a single request instead of seven separate ones. This means the entire dashboard appears at once rather than sections loading one by one, and puts much less strain on the server when many people are viewing their analytics at the same time. - **Smoother real-time updates.** The real-time visitors page now streams updates instantly from the server instead of checking for new data every few seconds. Visitors appear and disappear in real-time with no delay, and the page uses far fewer server resources — especially when many people are watching their live traffic at the same time. - **More reliable under heavy load.** Database queries now have automatic time limits so a single slow query can never lock up the system. If your dashboard or stats take too long to load, the request is gracefully cancelled instead of hanging forever — keeping everything responsive even during traffic spikes. - **Smarter caching for dashboard data.** Your dashboard stats are now cached for longer and shared more efficiently between requests. When the cache refreshes, only one request does the work while others wait for the result — so your dashboard loads consistently fast even when lots of people are viewing their analytics at the same time. - **Faster filtered views.** When you filter your dashboard by country, browser, page, or any other dimension, the results are now cached so repeat views load instantly. If multiple people apply the same filter, only one lookup runs and the result is shared — making filtered views much snappier under heavy use. - **Faster entry and exit page stats.** The queries that figure out which pages visitors land on and leave from have been rewritten to be much more efficient. Instead of sorting through every single event, they now look up just the first and last page per visit — so your Entry Pages and Exit Pages panels load noticeably faster, especially on high-traffic sites. +- **Faster goal stats.** The Goals panel on your dashboard now loads faster, especially for sites with many custom events. Goal names are now looked up in a single step instead of one at a time. +- **Fairer performance under heavy traffic.** One busy site can no longer slow down dashboards for everyone else. Each site now gets its own dedicated share of server resources, so your analytics stay fast and responsive even when other sites on the platform are experiencing traffic spikes. +- **Smoother exports.** Exporting your data to PDF, Excel, or CSV no longer freezes the page. You'll see a clear "Exporting..." indicator while your file is being prepared, and the rest of the dashboard stays fully interactive. +- **Smoother "View All" popups.** Opening the expanded view for Pages, Locations, Technology, Referrers, or Campaigns now scrolls smoothly even with hundreds of items. Only the rows you can see are rendered, so the popup opens instantly on any device. +- **Faster daily stats processing.** Behind the scenes, the system that calculates your daily visitor stats now automatically scales up when there are more sites to process — so your dashboard numbers stay accurate and up to date even as the platform grows. +- **More reliable background processing.** When multiple servers are running, long-running background tasks like daily stats calculations no longer risk being interrupted or duplicated. The system now keeps its coordination lock active for as long as the task is running. ### Added diff --git a/components/dashboard/Locations.tsx b/components/dashboard/Locations.tsx index 68fb707..b752447 100644 --- a/components/dashboard/Locations.tsx +++ b/components/dashboard/Locations.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import dynamic from 'next/dynamic' import { motion } from 'framer-motion' import { logger } from '@/lib/utils/logger' @@ -43,6 +43,20 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' const [fullData, setFullData] = useState([]) const [isLoadingFull, setIsLoadingFull] = useState(false) + const containerRef = useRef(null) + const [inView, setInView] = useState(false) + + useEffect(() => { + const el = containerRef.current + if (!el) return + const observer = new IntersectionObserver( + ([entry]) => { if (entry.isIntersecting) setInView(true) }, + { rootMargin: '200px' } + ) + observer.observe(el) + return () => observer.disconnect() + }, []) + useEffect(() => { if (isModalOpen) { const fetchData = async () => { @@ -202,7 +216,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' return ( <> -
+

@@ -252,8 +266,8 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' ) : isVisualTab ? ( hasData ? ( activeTab === 'globe' - ? - : + ? (inView ? : null) + : (inView ? : null) ) : (
From 205cdf314cce9872492dff18dd8ed0e0860a249b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 10 Mar 2026 21:19:33 +0100 Subject: [PATCH 072/109] perf: bound SWR cache, clean stale storage, cap annotations Add LRU cache provider (200 entries) to prevent unbounded SWR memory growth. Clean up stale PKCE localStorage keys on app init. Cap chart annotations to 20 visible reference lines with overflow indicator. --- CHANGELOG.md | 4 ++++ app/layout.tsx | 15 +++++++----- components/SWRProvider.tsx | 12 ++++++++++ components/dashboard/Chart.tsx | 14 ++++++++++-- lib/auth/context.tsx | 3 +++ lib/swr/cache-provider.ts | 42 ++++++++++++++++++++++++++++++++++ lib/utils/storage-cleanup.ts | 17 ++++++++++++++ 7 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 components/SWRProvider.tsx create mode 100644 lib/swr/cache-provider.ts create mode 100644 lib/utils/storage-cleanup.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c487ce..ce3740e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **More resilient under Redis outages.** If the caching layer goes down temporarily, Pulse now continues enforcing rate limits using an in-memory fallback instead of letting all traffic through unchecked. This prevents one infrastructure hiccup from snowballing into a bigger problem. - **Better handling of traffic bursts.** The system can now absorb 5x larger spikes of incoming events before applying backpressure. When events are dropped during extreme bursts, the system now tracks and logs exactly how many — so we can detect and respond to sustained overload before it affects your data. - **Faster map and globe loading.** The interactive 3D globe and dotted map in the Locations panel now only load when you scroll down to them, instead of rendering immediately on page load. This makes the initial dashboard load faster and saves battery on mobile devices. +- **Real-time updates work across all servers.** If Pulse runs on multiple servers behind a load balancer, real-time visitor updates now stay in sync no matter which server you're connected to. Previously, you might miss live visitor changes if your connection landed on a different server than the one fetching data. +- **Lighter memory usage in long sessions.** If you manage many sites and keep Pulse open for hours, the app now automatically clears out old cached data for sites you're no longer viewing. This keeps the tab responsive and prevents it from slowly using more and more memory over time. +- **Cleaner login storage.** Temporary data left behind by abandoned sign-in attempts is now cleaned up automatically when the app loads. This prevents clutter from building up in your browser's storage over time. +- **Tidier annotation display.** If you've added a lot of annotations to your chart, only the 20 most recent are shown as lines on the chart to keep it readable. A "+N more" label lets you know there are additional annotations. - **Even faster dashboard loading.** Your dashboard now fetches all its data — pages, locations, devices, referrers, performance, and goals — in a single request instead of seven separate ones. This means the entire dashboard appears at once rather than sections loading one by one, and puts much less strain on the server when many people are viewing their analytics at the same time. - **Smoother real-time updates.** The real-time visitors page now streams updates instantly from the server instead of checking for new data every few seconds. Visitors appear and disappear in real-time with no delay, and the page uses far fewer server resources — especially when many people are watching their live traffic at the same time. - **More reliable under heavy load.** Database queries now have automatic time limits so a single slow query can never lock up the system. If your dashboard or stats take too long to load, the request is gracefully cancelled instead of hanging forever — keeping everything responsive even during traffic spikes. diff --git a/app/layout.tsx b/app/layout.tsx index 459df64..13c4f7b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import { ThemeProviders, Toaster } from '@ciphera-net/ui' import { AuthProvider } from '@/lib/auth/context' +import SWRProvider from '@/components/SWRProvider' import type { Metadata, Viewport } from 'next' import { Plus_Jakarta_Sans } from 'next/font/google' import LayoutContent from './layout-content' @@ -46,12 +47,14 @@ export default function RootLayout({ return ( - - - {children} - - - + + + + {children} + + + + ) diff --git a/components/SWRProvider.tsx b/components/SWRProvider.tsx new file mode 100644 index 0000000..c3a18e6 --- /dev/null +++ b/components/SWRProvider.tsx @@ -0,0 +1,12 @@ +'use client' + +import { SWRConfig } from 'swr' +import { boundedCacheProvider } from '@/lib/swr/cache-provider' + +export default function SWRProvider({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index d40cad7..88e1bbe 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -19,6 +19,8 @@ const ANNOTATION_COLORS: Record = { other: '#a3a3a3', } +const MAX_VISIBLE_ANNOTATIONS = 20 + const ANNOTATION_LABELS: Record = { deploy: 'Deploy', campaign: 'Campaign', @@ -254,6 +256,9 @@ export default function Chart({ return markers }, [annotations, chartData]) + const visibleAnnotationMarkers = annotationMarkers.slice(0, MAX_VISIBLE_ANNOTATIONS) + const hiddenAnnotationCount = Math.max(0, annotationMarkers.length - MAX_VISIBLE_ANNOTATIONS) + // ─── Right-click handler ────────────────────────────────────────── const handleChartContextMenu = useCallback((e: React.MouseEvent) => { if (!canManageAnnotations) return @@ -490,7 +495,7 @@ export default function Chart({ /> {/* Annotation reference lines */} - {annotationMarkers.map((marker) => { + {visibleAnnotationMarkers.map((marker) => { const primaryCategory = marker.annotations[0].category const color = ANNOTATION_COLORS[primaryCategory] || ANNOTATION_COLORS.other return ( @@ -534,7 +539,7 @@ export default function Chart({ {annotationMarkers.length > 0 && ( <> Annotations: - {annotationMarkers.map((marker) => { + {visibleAnnotationMarkers.map((marker) => { const primary = marker.annotations[0] const color = ANNOTATION_COLORS[primary.category] || ANNOTATION_COLORS.other const count = marker.annotations.length @@ -577,6 +582,11 @@ export default function Chart({ ) })} + {hiddenAnnotationCount > 0 && ( + + +{hiddenAnnotationCount} more + + )} )}
diff --git a/lib/auth/context.tsx b/lib/auth/context.tsx index db49ca7..eeb8e90 100644 --- a/lib/auth/context.tsx +++ b/lib/auth/context.tsx @@ -7,6 +7,7 @@ import { LoadingOverlay, useSessionSync, SessionExpiryWarning } from '@ciphera-n import { logoutAction, getSessionAction, setSessionAction } from '@/app/actions/auth' import { getUserOrganizations, switchContext } from '@/lib/api/organization' import { logger } from '@/lib/utils/logger' +import { cleanupStaleStorage } from '@/lib/utils/storage-cleanup' interface User { id: string @@ -131,6 +132,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { // Initial load useEffect(() => { const init = async () => { + cleanupStaleStorage() + // * 1. Check server-side session (cookies) let session: Awaited> = null try { diff --git a/lib/swr/cache-provider.ts b/lib/swr/cache-provider.ts new file mode 100644 index 0000000..8aac5ec --- /dev/null +++ b/lib/swr/cache-provider.ts @@ -0,0 +1,42 @@ +// * Bounded LRU cache provider for SWR +// * Prevents unbounded memory growth during long sessions across many sites + +const MAX_CACHE_ENTRIES = 200 + +export function boundedCacheProvider() { + const map = new Map() + const accessOrder: string[] = [] + + const touch = (key: string) => { + const idx = accessOrder.indexOf(key) + if (idx > -1) accessOrder.splice(idx, 1) + accessOrder.push(key) + } + + const evict = () => { + while (map.size > MAX_CACHE_ENTRIES && accessOrder.length > 0) { + const oldest = accessOrder.shift()! + map.delete(oldest) + } + } + + return { + get(key: string) { + if (map.has(key)) touch(key) + return map.get(key) + }, + set(key: string, value: any) { + map.set(key, value) + touch(key) + evict() + }, + delete(key: string) { + map.delete(key) + const idx = accessOrder.indexOf(key) + if (idx > -1) accessOrder.splice(idx, 1) + }, + keys() { + return map.keys() + }, + } +} diff --git a/lib/utils/storage-cleanup.ts b/lib/utils/storage-cleanup.ts new file mode 100644 index 0000000..579192a --- /dev/null +++ b/lib/utils/storage-cleanup.ts @@ -0,0 +1,17 @@ +// * Cleans up stale localStorage entries on app initialization +// * Prevents accumulation from abandoned OAuth flows + +export function cleanupStaleStorage() { + if (typeof window === 'undefined') return + + try { + // * PKCE keys are only needed during the OAuth callback + // * If we're not on the callback page, they're stale leftovers + if (!window.location.pathname.includes('/auth/callback')) { + localStorage.removeItem('oauth_state') + localStorage.removeItem('oauth_code_verifier') + } + } catch { + // * Ignore errors (private browsing, storage disabled, etc.) + } +} From 3d12f35331546df8bc8ad41d09f64fbbe04a8f74 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 10 Mar 2026 23:55:47 +0100 Subject: [PATCH 073/109] chore: bump @ciphera-net/ui to 0.1.4 PasswordInput now forwards the required attribute to the underlying input element, enabling HTML5 form validation on password fields. --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4d30b79..fc85d63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.13.0-alpha", "dependencies": { - "@ciphera-net/ui": "^0.1.3", + "@ciphera-net/ui": "^0.1.4", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", @@ -1667,9 +1667,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.1.3", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.1.3/2fb3a50f2648b9206d662d2dfdeb1aed4a0e27af", - "integrity": "sha512-JPM2XvErK6iW7XEJ81jEc2wqF4/Ej9o33f1xTc4MkzdMX5H81kLkUpp9TrwL/m6LvZ85Baj3853osnOvm8Y1Hw==", + "version": "0.1.4", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.1.4/c074856f306e6fe4bda315821882be2d8e17ffdc", + "integrity": "sha512-O9JXNx9VwF6EicNyFgZ6QT5y1HbofuFMBuDt0KOTD9CmRMWR/kuaI4Iqw1o1hMcY5WW+rFfjF2W/Jjdr4b0iWw==", "dependencies": { "@phosphor-icons/react": "^2.1.10", "class-variance-authority": "^0.7.1", diff --git a/package.json b/package.json index ad5f188..887f7e8 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ciphera-net/ui": "^0.1.3", + "@ciphera-net/ui": "^0.1.4", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", From 37eb49eb3732e2a9bd82408301e93583f6990cfc Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Mar 2026 11:30:21 +0100 Subject: [PATCH 074/109] feat: action-scoped captcha tokens for share access and org settings Captcha on shared dashboard and organization settings now passes action-specific identifiers. Bumps @ciphera-net/ui to 0.2.1. --- CHANGELOG.md | 1 + app/share/[id]/page.tsx | 1 + components/settings/OrganizationSettings.tsx | 1 + package-lock.json | 8 ++++---- package.json | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce3740e..3f3c659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Improved +- **Smarter bot protection.** The security checks on shared dashboard access and organization settings now use action-specific tokens tied to each page. A token earned on one page can't be reused on another, making it harder for automated tools to bypass the captcha. - **More resilient under Redis outages.** If the caching layer goes down temporarily, Pulse now continues enforcing rate limits using an in-memory fallback instead of letting all traffic through unchecked. This prevents one infrastructure hiccup from snowballing into a bigger problem. - **Better handling of traffic bursts.** The system can now absorb 5x larger spikes of incoming events before applying backpressure. When events are dropped during extreme bursts, the system now tracks and logs exactly how many — so we can detect and respond to sustained overload before it affects your data. - **Faster map and globe loading.** The interactive 3D globe and dotted map in the Locations panel now only load when you scroll down to them, instead of rendering immediately on page load. This makes the initial dashboard load faster and saves battery on mobile devices. diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index cc28021..256f640 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -238,6 +238,7 @@ export default function PublicDashboardPage() { setCaptchaToken(token || '') }} apiUrl={process.env.NEXT_PUBLIC_CAPTCHA_API_URL} + action="share-access" />

diff --git a/package-lock.json b/package-lock.json index fc85d63..b190504 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.13.0-alpha", "dependencies": { - "@ciphera-net/ui": "^0.1.4", + "@ciphera-net/ui": "^0.2.1", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", @@ -1667,9 +1667,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.1.4", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.1.4/c074856f306e6fe4bda315821882be2d8e17ffdc", - "integrity": "sha512-O9JXNx9VwF6EicNyFgZ6QT5y1HbofuFMBuDt0KOTD9CmRMWR/kuaI4Iqw1o1hMcY5WW+rFfjF2W/Jjdr4b0iWw==", + "version": "0.2.1", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.1/c0e31eb55d837285e4c89f4a32228681f1282d7b", + "integrity": "sha512-ggIRrgwSS0TI/6ieU1esmV+6EIQ79Je5IM/pXqIzLUm1TJ68xGtx2Uumm36vMKSnQ3KqW+Li5HqfhzQJnH6fdw==", "dependencies": { "@phosphor-icons/react": "^2.1.10", "class-variance-authority": "^0.7.1", diff --git a/package.json b/package.json index 887f7e8..dc2c664 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ciphera-net/ui": "^0.1.4", + "@ciphera-net/ui": "^0.2.1", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", From 15d41f5bd924953b0949455011077dd3dd648ce0 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Mar 2026 11:39:16 +0100 Subject: [PATCH 075/109] fix: bump @ciphera-net/ui to 0.2.2 (PoW difficulty fix) --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index b190504..c49d0fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.13.0-alpha", "dependencies": { - "@ciphera-net/ui": "^0.2.1", + "@ciphera-net/ui": "^0.2.2", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", @@ -1667,9 +1667,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.2.1", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.1/c0e31eb55d837285e4c89f4a32228681f1282d7b", - "integrity": "sha512-ggIRrgwSS0TI/6ieU1esmV+6EIQ79Je5IM/pXqIzLUm1TJ68xGtx2Uumm36vMKSnQ3KqW+Li5HqfhzQJnH6fdw==", + "version": "0.2.2", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.2/9245f64a28201bfdea8873b7c809ee966825e2ed", + "integrity": "sha512-7yOWEJT9X9+hhzKKIRdLeUz5rkm2922UbVsUNf6dWMLoTrL0I1FKEzBjIHxshh6DZpQDDAGQD9D2ty5y4xog/Q==", "dependencies": { "@phosphor-icons/react": "^2.1.10", "class-variance-authority": "^0.7.1", diff --git a/package.json b/package.json index dc2c664..e4848c4 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ciphera-net/ui": "^0.2.1", + "@ciphera-net/ui": "^0.2.2", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", From 81362689884704d5edec0e2f562d4a12ef85fb10 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Mar 2026 11:53:04 +0100 Subject: [PATCH 076/109] fix: bump ciphera-ui to 0.2.3 and allow blob: in worker-src CSP Adds blob: to worker-src so the captcha PoW web worker can run. --- next.config.ts | 2 +- package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/next.config.ts b/next.config.ts index 05f96a5..f4d7991 100644 --- a/next.config.ts +++ b/next.config.ts @@ -16,7 +16,7 @@ const cspDirectives = [ "img-src 'self' data: blob: https://www.google.com https://*.gstatic.com https://ciphera.net", "font-src 'self'", `connect-src 'self' https://*.ciphera.net https://ciphera.net https://www.google.com https://*.gstatic.com https://cdn.jsdelivr.net${process.env.NODE_ENV === 'development' ? ' http://localhost:*' : ''}`, - "worker-src 'self'", + "worker-src 'self' blob:", "frame-src 'none'", "object-src 'none'", "base-uri 'self'", diff --git a/package-lock.json b/package-lock.json index c49d0fb..b6fba6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.13.0-alpha", "dependencies": { - "@ciphera-net/ui": "^0.2.2", + "@ciphera-net/ui": "^0.2.3", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", @@ -1667,9 +1667,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.2.2", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.2/9245f64a28201bfdea8873b7c809ee966825e2ed", - "integrity": "sha512-7yOWEJT9X9+hhzKKIRdLeUz5rkm2922UbVsUNf6dWMLoTrL0I1FKEzBjIHxshh6DZpQDDAGQD9D2ty5y4xog/Q==", + "version": "0.2.3", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.3/75e7a47468bdd46dcd1e1b55df475fdbf3650e7d", + "integrity": "sha512-MFhwn3q/LXMx9yWqza+VjozZfF4y4blk/ChUkQRBbarYKwIqxsoP10WTEcrSUZvuaTd2QfqFjA+ZcouI2NtuGQ==", "dependencies": { "@phosphor-icons/react": "^2.1.10", "class-variance-authority": "^0.7.1", diff --git a/package.json b/package.json index e4848c4..e5f0210 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ciphera-net/ui": "^0.2.2", + "@ciphera-net/ui": "^0.2.3", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", From c2d5935394af6536ba7b8980271dac002469ecc1 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Mar 2026 21:54:24 +0100 Subject: [PATCH 077/109] security: send X-CSRF-Token on all state-changing API requests (F-01) --- lib/api/client.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/api/client.ts b/lib/api/client.ts index 13bd1cb..9b82f2b 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -202,9 +202,9 @@ async function apiRequest( // * We rely on HttpOnly cookies, so no manual Authorization header injection. // * We MUST set credentials: 'include' for the browser to send cookies cross-origin (or same-site). - // * Add CSRF token for state-changing requests to Auth API - // * Auth API uses Double Submit Cookie pattern for CSRF protection - if (isAuthRequest && isStateChangingMethod(method)) { + // * Add CSRF token for all state-changing requests (Pulse API and Auth API). + // * Both backends enforce the double-submit cookie pattern server-side. + if (isStateChangingMethod(method)) { const csrfToken = getCSRFToken() if (csrfToken) { headers['X-CSRF-Token'] = csrfToken From 2fa3540a4804d4e6c35384ab018ca7d32eb732a8 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Mar 2026 22:15:59 +0100 Subject: [PATCH 078/109] feat: polish Goals & Events dashboard panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align visual style with Pages, Referrers, and Locations panels — flat rows with hover state, rank numbers, muted count colors, and a slide-in percentage on hover. Add empty slots for consistent height. --- CHANGELOG.md | 1 + components/dashboard/GoalStats.tsx | 33 ++++++++++++++++++++++-------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f3c659..a356cb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Improved +- **Polished Goals & Events panel.** The Goals & Events block on your dashboard got a visual refresh to match the style of the Pages, Referrers, and Locations panels. Events are now ranked with a number on the left, counts are shown in a consistent style, and hovering any row reveals what percentage of total events that action accounts for — sliding in smoothly from the right. - **Smarter bot protection.** The security checks on shared dashboard access and organization settings now use action-specific tokens tied to each page. A token earned on one page can't be reused on another, making it harder for automated tools to bypass the captcha. - **More resilient under Redis outages.** If the caching layer goes down temporarily, Pulse now continues enforcing rate limits using an in-memory fallback instead of letting all traffic through unchecked. This prevents one infrastructure hiccup from snowballing into a bigger problem. - **Better handling of traffic bursts.** The system can now absorb 5x larger spikes of incoming events before applying backpressure. When events are dropped during extreme bursts, the system now tracks and logs exactly how many — so we can detect and respond to sustained overload before it affects your data. diff --git a/components/dashboard/GoalStats.tsx b/components/dashboard/GoalStats.tsx index f641af7..03c3554 100644 --- a/components/dashboard/GoalStats.tsx +++ b/components/dashboard/GoalStats.tsx @@ -15,6 +15,8 @@ const LIMIT = 10 export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps) { const list = (goalCounts || []).slice(0, LIMIT) const hasData = list.length > 0 + const total = list.reduce((sum, r) => sum + r.count, 0) + const emptySlots = Math.max(0, 6 - list.length) return (
@@ -25,21 +27,34 @@ export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps)
{hasData ? ( -
- {list.map((row) => ( +
+ {list.map((row, i) => (
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' : ''}`} + className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onSelectEvent ? ' cursor-pointer' : ''}`} > - - {row.display_name ?? row.event_name.replace(/_/g, ' ')} - - - {formatNumber(row.count)} - +
+ + {i + 1} + + + {row.display_name ?? row.event_name.replace(/_/g, ' ')} + +
+
+ + {total > 0 ? `${Math.round((row.count / total) * 100)}%` : ''} + + + {formatNumber(row.count)} + +
))} + {Array.from({ length: emptySlots }).map((_, i) => ( + ) : (
From 55bf20c58d42bf4ba7446b2cbc98c3d08683d1f1 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Mar 2026 22:19:39 +0100 Subject: [PATCH 079/109] fix: remove rank numbers from Goals & Events panel --- components/dashboard/GoalStats.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/components/dashboard/GoalStats.tsx b/components/dashboard/GoalStats.tsx index 03c3554..ebbc9a5 100644 --- a/components/dashboard/GoalStats.tsx +++ b/components/dashboard/GoalStats.tsx @@ -28,16 +28,13 @@ export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps) {hasData ? (
- {list.map((row, i) => ( + {list.map((row) => (
onSelectEvent?.(row.event_name)} className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onSelectEvent ? ' cursor-pointer' : ''}`} > -
- - {i + 1} - +
{row.display_name ?? row.event_name.replace(/_/g, ' ')} From faa2f50d6e2c23489d4771dd03d5ef0971b2272a Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Mar 2026 22:22:59 +0100 Subject: [PATCH 080/109] feat: replace scroll depth bar chart with radar chart --- CHANGELOG.md | 1 + components/dashboard/ScrollDepth.tsx | 68 +++++++++++++++------------- 2 files changed, 38 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a356cb1..3e9d3a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Improved +- **Scroll Depth is now a radar chart.** The Scroll Depth panel has been redesigned from a bar chart into a radar chart. The four scroll milestones (25%, 50%, 75%, 100%) are plotted as axes, with the filled shape showing how far visitors are getting through your pages at a glance. - **Polished Goals & Events panel.** The Goals & Events block on your dashboard got a visual refresh to match the style of the Pages, Referrers, and Locations panels. Events are now ranked with a number on the left, counts are shown in a consistent style, and hovering any row reveals what percentage of total events that action accounts for — sliding in smoothly from the right. - **Smarter bot protection.** The security checks on shared dashboard access and organization settings now use action-specific tokens tied to each page. A token earned on one page can't be reused on another, making it harder for automated tools to bypass the captcha. - **More resilient under Redis outages.** If the caching layer goes down temporarily, Pulse now continues enforcing rate limits using an in-memory fallback instead of letting all traffic through unchecked. This prevents one infrastructure hiccup from snowballing into a bigger problem. diff --git a/components/dashboard/ScrollDepth.tsx b/components/dashboard/ScrollDepth.tsx index 92f416f..d9157cb 100644 --- a/components/dashboard/ScrollDepth.tsx +++ b/components/dashboard/ScrollDepth.tsx @@ -1,6 +1,6 @@ 'use client' -import { formatNumber } from '@ciphera-net/ui' +import { PolarAngleAxis, PolarGrid, Radar, RadarChart, Tooltip } from 'recharts' import { BarChartIcon } from '@ciphera-net/ui' import type { GoalCountStat } from '@/lib/api/stats' @@ -22,6 +22,11 @@ export default function ScrollDepth({ goalCounts, totalPageviews }: ScrollDepthP const hasData = scrollCounts.size > 0 && totalPageviews > 0 + const chartData = THRESHOLDS.map((threshold) => ({ + label: `${threshold}%`, + value: totalPageviews > 0 ? Math.round(((scrollCounts.get(threshold) ?? 0) / totalPageviews) * 100) : 0, + })) + return (
@@ -31,36 +36,37 @@ export default function ScrollDepth({ goalCounts, totalPageviews }: ScrollDepthP
{hasData ? ( -
- {THRESHOLDS.map((threshold) => { - const count = scrollCounts.get(threshold) ?? 0 - const pct = totalPageviews > 0 ? Math.round((count / totalPageviews) * 100) : 0 - const barWidth = Math.max(pct, 2) - - return ( -
-
- - {threshold}% - -
- - {formatNumber(count)} - - - {pct}% - -
-
-
-
-
-
- ) - })} +
+ + + + [`${value}%`, 'Reached']} + /> + +
) : (
From 0f5d5338f36497470430051de20c6627b2d07ceb Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Mar 2026 22:26:15 +0100 Subject: [PATCH 081/109] fix: make scroll depth block half-width and enlarge radar chart --- app/sites/[id]/page.tsx | 8 ++++---- components/dashboard/ScrollDepth.tsx | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index a220bc3..c7e4762 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -584,15 +584,15 @@ export default function SiteDashboardPage() { />
-
+
+
+ +
!/^scroll_\d+$/.test(g.event_name))} onSelectEvent={setSelectedEvent} /> -
- -
diff --git a/components/dashboard/ScrollDepth.tsx b/components/dashboard/ScrollDepth.tsx index d9157cb..9409420 100644 --- a/components/dashboard/ScrollDepth.tsx +++ b/components/dashboard/ScrollDepth.tsx @@ -38,10 +38,10 @@ export default function ScrollDepth({ goalCounts, totalPageviews }: ScrollDepthP {hasData ? (
Date: Wed, 11 Mar 2026 22:30:19 +0100 Subject: [PATCH 082/109] fix: add 0% baseline axis to scroll depth radar for pentagon shape --- components/dashboard/ScrollDepth.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/components/dashboard/ScrollDepth.tsx b/components/dashboard/ScrollDepth.tsx index 9409420..fa53586 100644 --- a/components/dashboard/ScrollDepth.tsx +++ b/components/dashboard/ScrollDepth.tsx @@ -22,10 +22,13 @@ export default function ScrollDepth({ goalCounts, totalPageviews }: ScrollDepthP const hasData = scrollCounts.size > 0 && totalPageviews > 0 - const chartData = THRESHOLDS.map((threshold) => ({ - label: `${threshold}%`, - value: totalPageviews > 0 ? Math.round(((scrollCounts.get(threshold) ?? 0) / totalPageviews) * 100) : 0, - })) + const chartData = [ + { label: '0%', value: 100 }, + ...THRESHOLDS.map((threshold) => ({ + label: `${threshold}%`, + value: totalPageviews > 0 ? Math.round(((scrollCounts.get(threshold) ?? 0) / totalPageviews) * 100) : 0, + })), + ] return (
From ca60379e5e131b801c0d3e49c30976a8283bba98 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Mar 2026 22:36:55 +0100 Subject: [PATCH 083/109] feat: replace radar with clean bar chart for scroll depth --- components/dashboard/ScrollDepth.tsx | 83 ++++++++++++++-------------- 1 file changed, 43 insertions(+), 40 deletions(-) diff --git a/components/dashboard/ScrollDepth.tsx b/components/dashboard/ScrollDepth.tsx index fa53586..531bc3d 100644 --- a/components/dashboard/ScrollDepth.tsx +++ b/components/dashboard/ScrollDepth.tsx @@ -1,6 +1,6 @@ 'use client' -import { PolarAngleAxis, PolarGrid, Radar, RadarChart, Tooltip } from 'recharts' +import { Bar, BarChart, CartesianGrid, LabelList, XAxis, ResponsiveContainer, Tooltip } from 'recharts' import { BarChartIcon } from '@ciphera-net/ui' import type { GoalCountStat } from '@/lib/api/stats' @@ -22,54 +22,57 @@ export default function ScrollDepth({ goalCounts, totalPageviews }: ScrollDepthP const hasData = scrollCounts.size > 0 && totalPageviews > 0 - const chartData = [ - { label: '0%', value: 100 }, - ...THRESHOLDS.map((threshold) => ({ - label: `${threshold}%`, - value: totalPageviews > 0 ? Math.round(((scrollCounts.get(threshold) ?? 0) / totalPageviews) * 100) : 0, - })), - ] + const chartData = THRESHOLDS.map((threshold) => ({ + label: `${threshold}%`, + value: totalPageviews > 0 ? Math.round(((scrollCounts.get(threshold) ?? 0) / totalPageviews) * 100) : 0, + })) return (
-
+

Scroll Depth

+

+ % of visitors who scrolled this far +

{hasData ? ( -
- - - - [`${value}%`, 'Reached']} - /> - - +
+ + + + + [`${value}%`, 'Reached']} + /> + + `${v}%`} + /> + + +
) : (
From bf37add36686910aaf69f9c122af13fdf3dd72c8 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Mar 2026 22:42:32 +0100 Subject: [PATCH 084/109] revert: restore radar chart for scroll depth (4 axes, no 0% anchor) --- components/dashboard/ScrollDepth.tsx | 72 +++++++++++++--------------- 1 file changed, 33 insertions(+), 39 deletions(-) diff --git a/components/dashboard/ScrollDepth.tsx b/components/dashboard/ScrollDepth.tsx index 531bc3d..9409420 100644 --- a/components/dashboard/ScrollDepth.tsx +++ b/components/dashboard/ScrollDepth.tsx @@ -1,6 +1,6 @@ 'use client' -import { Bar, BarChart, CartesianGrid, LabelList, XAxis, ResponsiveContainer, Tooltip } from 'recharts' +import { PolarAngleAxis, PolarGrid, Radar, RadarChart, Tooltip } from 'recharts' import { BarChartIcon } from '@ciphera-net/ui' import type { GoalCountStat } from '@/lib/api/stats' @@ -29,50 +29,44 @@ export default function ScrollDepth({ goalCounts, totalPageviews }: ScrollDepthP return (
-
+

Scroll Depth

-

- % of visitors who scrolled this far -

{hasData ? ( -
- - - - - [`${value}%`, 'Reached']} - /> - - `${v}%`} - /> - - - +
+ + + + [`${value}%`, 'Reached']} + /> + +
) : (
From 7431f2b78d61f14f03c238246fc305fede9d3917 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Mar 2026 22:45:36 +0100 Subject: [PATCH 085/109] fix: increase radar fill opacity to cover grid lines --- components/dashboard/ScrollDepth.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/dashboard/ScrollDepth.tsx b/components/dashboard/ScrollDepth.tsx index 9409420..996ddc9 100644 --- a/components/dashboard/ScrollDepth.tsx +++ b/components/dashboard/ScrollDepth.tsx @@ -63,7 +63,7 @@ export default function ScrollDepth({ goalCounts, totalPageviews }: ScrollDepthP dataKey="value" stroke="#FD5E0F" fill="#FD5E0F" - fillOpacity={0.25} + fillOpacity={0.6} dot={{ r: 4, fill: '#FD5E0F', fillOpacity: 1, strokeWidth: 0 }} /> From 72011dea5c74e1b0aa2f2d8252007633edf7dada Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Mar 2026 22:48:37 +0100 Subject: [PATCH 086/109] fix: enlarge scroll depth radar chart --- components/dashboard/ScrollDepth.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/dashboard/ScrollDepth.tsx b/components/dashboard/ScrollDepth.tsx index 996ddc9..8c084d9 100644 --- a/components/dashboard/ScrollDepth.tsx +++ b/components/dashboard/ScrollDepth.tsx @@ -38,10 +38,10 @@ export default function ScrollDepth({ goalCounts, totalPageviews }: ScrollDepthP {hasData ? (
Date: Wed, 11 Mar 2026 22:49:54 +0100 Subject: [PATCH 087/109] fix: add subtitle to scroll depth radar chart --- components/dashboard/ScrollDepth.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/components/dashboard/ScrollDepth.tsx b/components/dashboard/ScrollDepth.tsx index 8c084d9..4258243 100644 --- a/components/dashboard/ScrollDepth.tsx +++ b/components/dashboard/ScrollDepth.tsx @@ -29,11 +29,14 @@ export default function ScrollDepth({ goalCounts, totalPageviews }: ScrollDepthP return (
-
+

Scroll Depth

+

+ % of visitors who scrolled this far +

{hasData ? (
From 0754cb0e4f1292a1f7a7bc6ce2382fd4327ed602 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Mar 2026 22:52:14 +0100 Subject: [PATCH 088/109] fix: align Goals & Events and Scroll Depth block height with other dashboard blocks --- components/dashboard/GoalStats.tsx | 4 ++-- components/dashboard/ScrollDepth.tsx | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/components/dashboard/GoalStats.tsx b/components/dashboard/GoalStats.tsx index ebbc9a5..8874081 100644 --- a/components/dashboard/GoalStats.tsx +++ b/components/dashboard/GoalStats.tsx @@ -27,7 +27,7 @@ export default function GoalStats({ goalCounts, onSelectEvent }: GoalStatsProps)
{hasData ? ( -
+
{list.map((row) => (
) : ( -
+
diff --git a/components/dashboard/ScrollDepth.tsx b/components/dashboard/ScrollDepth.tsx index 4258243..e66b993 100644 --- a/components/dashboard/ScrollDepth.tsx +++ b/components/dashboard/ScrollDepth.tsx @@ -39,12 +39,12 @@ export default function ScrollDepth({ goalCounts, totalPageviews }: ScrollDepthP

{hasData ? ( -
+
) : ( -
+
From 73db65c0b2969455be0cb504f5bead499ea0bb61 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Mar 2026 23:10:16 +0100 Subject: [PATCH 089/109] feat: redesign chart stat headers and fix badge semantic colors --- components/dashboard/Chart.tsx | 24 ++++++++++++------------ components/ui/badge-2.tsx | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index 88e1bbe..56b22eb 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -4,12 +4,11 @@ import { useState, useMemo, useRef, useCallback, useEffect } from 'react' import { useTheme } from '@ciphera-net/ui' import { Line, LineChart, XAxis, YAxis, ReferenceLine } from 'recharts' import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/line-charts-6' -import { Badge } from '@/components/ui/badge-2' import { Card, CardContent, CardHeader } from '@/components/ui/card' import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui' import { Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui' import { Checkbox } from '@ciphera-net/ui' -import { ArrowUp, ArrowDown } from '@phosphor-icons/react' +import { ArrowUpRight, ArrowDownRight } from '@phosphor-icons/react' import { cn } from '@/lib/utils' const ANNOTATION_COLORS: Record = { @@ -345,22 +344,23 @@ export default function Chart({ key={m.key} onClick={() => setMetric(m.key)} className={cn( - 'cursor-pointer flex-1 text-start p-4 border-b md:border-b-0 md:border-r md:last:border-r-0 border-neutral-200 dark:border-neutral-800 transition-all', + 'relative cursor-pointer flex-1 text-start p-4 border-b md:border-b-0 md:border-r md:last:border-r-0 border-neutral-200 dark:border-neutral-800 transition-all', metric === m.key && 'bg-neutral-50 dark:bg-neutral-800/40', )} > -
- {m.label} +
{m.label}
+
+ {m.format(m.value)} {m.change !== null && ( - - {m.isPositive ? : } - {Math.abs(m.change).toFixed(1)}% - + + {m.isPositive ? : } + {Math.abs(m.change).toFixed(0)}% + )}
-
{m.format(m.value)}
- {m.previousValue != null && ( -
from {m.format(m.previousValue)}
+
vs yesterday
+ {metric === m.key && ( +
)} ))} diff --git a/components/ui/badge-2.tsx b/components/ui/badge-2.tsx index 4bffe13..bcd3fb4 100644 --- a/components/ui/badge-2.tsx +++ b/components/ui/badge-2.tsx @@ -100,7 +100,7 @@ const badgeVariants = cva( variant: 'success', appearance: 'outline', className: - 'text-green-700 border-green-200 bg-green-50 dark:bg-green-950 dark:border-green-900 dark:text-green-600', + 'text-[#10B981] border-[#10B981]/20 bg-[#10B981]/10', }, { variant: 'warning', @@ -118,7 +118,7 @@ const badgeVariants = cva( variant: 'destructive', appearance: 'outline', className: - 'text-red-700 border-red-100 bg-red-50 dark:bg-red-950 dark:border-red-900 dark:text-red-600', + 'text-[#EF4444] border-[#EF4444]/20 bg-[#EF4444]/10', }, /* Ghost */ { From 275503ae8f237bb089075a69f2853a39d7ce1291 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Mar 2026 23:14:35 +0100 Subject: [PATCH 090/109] fix: show dynamic comparison period label in stat headers --- components/dashboard/Chart.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index 56b22eb..f6e4233 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -358,7 +358,10 @@ export default function Chart({ )}
-
vs yesterday
+
{(() => { + const days = Math.round((new Date(dateRange.end).getTime() - new Date(dateRange.start).getTime()) / 86400000) + 1 + return days <= 1 ? 'vs yesterday' : `vs previous ${days} days` + })()}
{metric === m.key && (
)} From 1c5ca7fa547b411353a0d15d165c9abc06250eb0 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Mar 2026 23:15:59 +0100 Subject: [PATCH 091/109] fix: active metric label white and slightly smaller --- components/dashboard/Chart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index f6e4233..8a55288 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -348,7 +348,7 @@ export default function Chart({ metric === m.key && 'bg-neutral-50 dark:bg-neutral-800/40', )} > -
{m.label}
+
{m.label}
{m.format(m.value)} {m.change !== null && ( From 34eca6496754da80ee488ac466b0377ab3237470 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Mar 2026 23:23:39 +0100 Subject: [PATCH 092/109] fix: correct off-by-one in comparison period label --- components/dashboard/Chart.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index 8a55288..bdfa732 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -359,8 +359,8 @@ export default function Chart({ )}
{(() => { - const days = Math.round((new Date(dateRange.end).getTime() - new Date(dateRange.start).getTime()) / 86400000) + 1 - return days <= 1 ? 'vs yesterday' : `vs previous ${days} days` + const days = Math.round((new Date(dateRange.end).getTime() - new Date(dateRange.start).getTime()) / 86400000) + return days === 0 ? 'vs yesterday' : `vs previous ${days} days` })()}
{metric === m.key && (
From b5dd5e7082967ec591a66d88dc6a0453fa2430ca Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Mar 2026 23:33:24 +0100 Subject: [PATCH 093/109] feat: add This week / This month period options and fix comparison labels --- CHANGELOG.md | 4 ++ app/sites/[id]/page.tsx | 71 +++++++++++++++++++++++----------- components/dashboard/Chart.tsx | 17 ++++++-- 3 files changed, 66 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e9d3a5..fce77f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Improved +- **New date range options.** The period selector now includes "This week" (Monday to today) and "This month" (1st to today) alongside the existing rolling windows. Your selection is remembered between sessions. +- **Smarter comparison labels.** The "vs …" label under each stat now matches the period you're viewing — "vs yesterday" for today, "vs last week" for this week, "vs last month" for this month, and "vs previous N days" for rolling windows. +- **Refreshed stat headers.** The Unique Visitors, Total Pageviews, Bounce Rate, and Visit Duration stats at the top of the chart have a new look — uppercase labels, the percentage change shown inline next to the number, and an orange underline on whichever metric you're currently graphing. +- **Consistent green and red colors.** The up/down percentage indicators now use the same green and red as the rest of the app, instead of slightly different shades. - **Scroll Depth is now a radar chart.** The Scroll Depth panel has been redesigned from a bar chart into a radar chart. The four scroll milestones (25%, 50%, 75%, 100%) are plotted as axes, with the filled shape showing how far visitors are getting through your pages at a glance. - **Polished Goals & Events panel.** The Goals & Events block on your dashboard got a visual refresh to match the style of the Pages, Referrers, and Locations panels. Events are now ranked with a number on the left, counts are shown in a consistent style, and hovering any row reveals what percentage of total events that action accounts for — sliding in smoothly from the right. - **Smarter bot protection.** The security checks on shared dashboard access and organization settings now use action-specific tokens tied to each page. A token earned on one page can't be reused on another, making it harder for automated tools to bypass the captcha. diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index c7e4762..9bb8a15 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -64,6 +64,20 @@ function loadSavedSettings(): { } } +function getThisWeekRange(): { start: string; end: string } { + const today = new Date() + const dayOfWeek = today.getDay() + const monday = new Date(today) + monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)) + return { start: formatDate(monday), end: formatDate(today) } +} + +function getThisMonthRange(): { start: string; end: string } { + const today = new Date() + const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1) + return { start: formatDate(firstOfMonth), end: formatDate(today) } +} + function getInitialDateRange(): { start: string; end: string } { const settings = loadSavedSettings() if (settings?.type === 'today') { @@ -71,10 +85,16 @@ function getInitialDateRange(): { start: string; end: string } { return { start: today, end: today } } if (settings?.type === '7') return getDateRange(7) + if (settings?.type === 'week') return getThisWeekRange() + if (settings?.type === 'month') return getThisMonthRange() if (settings?.type === 'custom' && settings.dateRange) return settings.dateRange return getDateRange(30) } +function getInitialPeriod(): string { + return loadSavedSettings()?.type || '30' +} + export default function SiteDashboardPage() { @@ -84,6 +104,7 @@ export default function SiteDashboardPage() { const siteId = params.id as string // UI state - initialized from localStorage synchronously to avoid double-fetch + const [period, setPeriod] = useState(getInitialPeriod) const [dateRange, setDateRange] = useState(getInitialDateRange) const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>( () => loadSavedSettings()?.todayInterval || 'hour' @@ -457,40 +478,44 @@ export default function SiteDashboardPage() {