From 51648c82c7da70604a23b68f3ba1815ded46ab3b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Feb 2026 16:34:44 +0100 Subject: [PATCH 1/3] chore: update CHANGELOG.md for 0.3.0-alpha release and implement favicon support in Top Referrers component --- CHANGELOG.md | 9 ++++++++- components/dashboard/TopReferrers.tsx | 23 ++++++++++++++++++++--- lib/utils/icons.tsx | 23 ++++++++++++++++------- package.json | 2 +- 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e96b62c..e8f8404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to Pulse (frontend and product) are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Pulse uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a **0.x.y** version scheme while in initial development. The leading `0` indicates that the public API and behaviour may change until we release **1.0.0**. +## [0.3.0-alpha] - 2026-02-11 + +### Changed + +- **Top Referrers favicons (PULSE-52).** The Top Referrers card now shows real site favicons (e.g. Google, ChatGPT, Instagram) when the referrer is a domain or URL. “Direct” and “Unknown” keep the globe icon; if a favicon fails to load, the previous icon is shown as fallback. + ## [0.2.0-alpha] - 2026-02-11 ### Added @@ -21,6 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), --- -[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.2.0-alpha...HEAD +[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.3.0-alpha...HEAD +[0.3.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.2.0-alpha...v0.3.0-alpha [0.2.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.1.0-alpha...v0.2.0-alpha [0.1.0-alpha]: https://github.com/ciphera-net/pulse/releases/tag/v0.1.0-alpha diff --git a/components/dashboard/TopReferrers.tsx b/components/dashboard/TopReferrers.tsx index 9d43b1c..4974f5d 100644 --- a/components/dashboard/TopReferrers.tsx +++ b/components/dashboard/TopReferrers.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import { formatNumber } from '@/lib/utils/format' -import { getReferrerIcon } from '@/lib/utils/icons' +import { getReferrerFavicon, getReferrerIcon } from '@/lib/utils/icons' import { Modal, GlobeIcon } from '@ciphera-net/ui' import { getTopReferrers, TopReferrer } from '@/lib/api/stats' @@ -19,6 +19,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI const [isModalOpen, setIsModalOpen] = useState(false) const [fullData, setFullData] = useState([]) const [isLoadingFull, setIsLoadingFull] = useState(false) + const [faviconFailed, setFaviconFailed] = useState>(new Set()) // Filter out empty/unknown referrers const filteredReferrers = (referrers || []).filter( @@ -30,6 +31,22 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI const emptySlots = Math.max(0, LIMIT - displayedReferrers.length) const showViewAll = hasData && filteredReferrers.length > LIMIT + function renderReferrerIcon(referrer: string) { + const faviconUrl = getReferrerFavicon(referrer) + const useFavicon = faviconUrl && !faviconFailed.has(referrer) + if (useFavicon) { + return ( + setFaviconFailed((prev) => new Set(prev).add(referrer))} + /> + ) + } + return {getReferrerIcon(referrer)} + } + useEffect(() => { if (isModalOpen) { const fetchData = async () => { @@ -80,7 +97,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI {displayedReferrers.map((ref, index) => (
- {getReferrerIcon(ref.referrer)} + {renderReferrerIcon(ref.referrer)} {ref.referrer}
@@ -123,7 +140,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI (fullData.length > 0 ? fullData : filteredReferrers).map((ref, index) => (
- {getReferrerIcon(ref.referrer)} + {renderReferrerIcon(ref.referrer)} {ref.referrer}
diff --git a/lib/utils/icons.tsx b/lib/utils/icons.tsx index 078b6f2..7e93641 100644 --- a/lib/utils/icons.tsx +++ b/lib/utils/icons.tsx @@ -78,11 +78,20 @@ export function getReferrerIcon(referrerName: string) { return } -export function getReferrerFavicon(referrer: string) { - try { - const url = new URL(referrer.startsWith('http') ? referrer : `https://${referrer}`); - return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32`; - } catch (e) { - return null; - } +const REFERRER_NO_FAVICON = ['direct', 'unknown', ''] + +/** + * Returns a favicon URL for the referrer's domain, or null for non-URL referrers + * (e.g. "Direct", "Unknown") so callers can show an icon fallback instead. + */ +export function getReferrerFavicon(referrer: string): string | null { + if (!referrer || typeof referrer !== 'string') return null + const normalized = referrer.trim().toLowerCase() + if (REFERRER_NO_FAVICON.includes(normalized)) return null + try { + const url = new URL(referrer.startsWith('http') ? referrer : `https://${referrer}`) + return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32` + } catch { + return null + } } diff --git a/package.json b/package.json index 98ab95e..97b583e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-frontend", - "version": "0.1.0", + "version": "0.3.0-alpha", "private": true, "scripts": { "dev": "next dev", From ddbb2494b169b92397bec95b7925da0a21f978a0 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Feb 2026 16:43:35 +0100 Subject: [PATCH 2/3] feat: add getReferrerDisplayName utility to enhance referrer display in ExportModal and TopReferrers components --- components/dashboard/ExportModal.tsx | 3 +- components/dashboard/TopReferrers.tsx | 6 +-- lib/utils/icons.tsx | 63 +++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/components/dashboard/ExportModal.tsx b/components/dashboard/ExportModal.tsx index bd0da91..86ccd64 100644 --- a/components/dashboard/ExportModal.tsx +++ b/components/dashboard/ExportModal.tsx @@ -7,6 +7,7 @@ import jsPDF from 'jspdf' import autoTable from 'jspdf-autotable' import type { DailyStat } from './Chart' import { formatNumber, formatDuration } from '@/lib/utils/format' +import { getReferrerDisplayName } from '@/lib/utils/icons' import type { TopPage, TopReferrer } from '@/lib/api/stats' interface ExportModalProps { @@ -278,7 +279,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to doc.text('Top Referrers', 14, finalY) finalY += 5 - const referrersData = topReferrers.slice(0, 10).map(r => [r.referrer, formatNumber(r.pageviews)]) + const referrersData = topReferrers.slice(0, 10).map(r => [getReferrerDisplayName(r.referrer), formatNumber(r.pageviews)]) autoTable(doc, { startY: finalY, diff --git a/components/dashboard/TopReferrers.tsx b/components/dashboard/TopReferrers.tsx index 4974f5d..05add44 100644 --- a/components/dashboard/TopReferrers.tsx +++ b/components/dashboard/TopReferrers.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import { formatNumber } from '@/lib/utils/format' -import { getReferrerFavicon, getReferrerIcon } from '@/lib/utils/icons' +import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon } from '@/lib/utils/icons' import { Modal, GlobeIcon } from '@ciphera-net/ui' import { getTopReferrers, TopReferrer } from '@/lib/api/stats' @@ -98,7 +98,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
{renderReferrerIcon(ref.referrer)} - {ref.referrer} + {getReferrerDisplayName(ref.referrer)}
{formatNumber(ref.pageviews)} @@ -141,7 +141,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
{renderReferrerIcon(ref.referrer)} - {ref.referrer} + {getReferrerDisplayName(ref.referrer)}
{formatNumber(ref.pageviews)} diff --git a/lib/utils/icons.tsx b/lib/utils/icons.tsx index 7e93641..0293253 100644 --- a/lib/utils/icons.tsx +++ b/lib/utils/icons.tsx @@ -80,6 +80,69 @@ export function getReferrerIcon(referrerName: string) { const REFERRER_NO_FAVICON = ['direct', 'unknown', ''] +/** + * Map of referrer hostname (lowercase) to display name for the Top Referrers list. + * Unknown hostnames fall back to the original referrer string. + */ +const REFERRER_DISPLAY_NAMES: Record = { + 'google.com': 'Google', + 'www.google.com': 'Google', + 'google.co.uk': 'Google', + 'google.de': 'Google', + 'facebook.com': 'Facebook', + 'www.facebook.com': 'Facebook', + 'm.facebook.com': 'Facebook', + 'instagram.com': 'Instagram', + 'www.instagram.com': 'Instagram', + 'l.instagram.com': 'Instagram', + 'twitter.com': 'X', + 'www.twitter.com': 'X', + 't.co': 'X', + 'x.com': 'X', + 'linkedin.com': 'LinkedIn', + 'www.linkedin.com': 'LinkedIn', + 'github.com': 'GitHub', + 'www.github.com': 'GitHub', + 'youtube.com': 'YouTube', + 'www.youtube.com': 'YouTube', + 'reddit.com': 'Reddit', + 'www.reddit.com': 'Reddit', + 'chatgpt.com': 'ChatGPT', + 'www.chatgpt.com': 'ChatGPT', + 'ciphera.net': 'Ciphera', + 'www.ciphera.net': 'Ciphera', +} + +/** + * Returns the hostname for a referrer string (URL or plain hostname), or null if invalid. + */ +function getReferrerHostname(referrer: string): string | null { + if (!referrer || typeof referrer !== 'string') return null + const trimmed = referrer.trim() + if (REFERRER_NO_FAVICON.includes(trimmed.toLowerCase())) return null + try { + const url = new URL(trimmed.startsWith('http') ? trimmed : `https://${trimmed}`) + return url.hostname.toLowerCase() + } catch { + return null + } +} + +/** + * Returns a friendly display name for the referrer (e.g. "Google" instead of "google.com"). + * Falls back to the original referrer string when no mapping exists. + */ +export function getReferrerDisplayName(referrer: string): string { + if (!referrer || typeof referrer !== 'string') return referrer || '' + const trimmed = referrer.trim() + if (trimmed === '') return '' + const hostname = getReferrerHostname(trimmed) + if (!hostname) return trimmed + const displayName = REFERRER_DISPLAY_NAMES[hostname] + if (displayName) return displayName + return trimmed +} + /** * Returns a favicon URL for the referrer's domain, or null for non-URL referrers * (e.g. "Direct", "Unknown") so callers can show an icon fallback instead. From 16d8765f4639c6f05cca23660f278d468f663ae4 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Feb 2026 16:57:01 +0100 Subject: [PATCH 3/3] feat: enhance Top Referrers display by merging referrer rows and adding friendly names --- CHANGELOG.md | 2 + components/dashboard/ExportModal.tsx | 5 +- components/dashboard/TopReferrers.tsx | 12 +-- lib/utils/icons.tsx | 102 ++++++++++++++++++-------- 4 files changed, 83 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8f8404..d1a84ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Changed - **Top Referrers favicons (PULSE-52).** The Top Referrers card now shows real site favicons (e.g. Google, ChatGPT, Instagram) when the referrer is a domain or URL. “Direct” and “Unknown” keep the globe icon; if a favicon fails to load, the previous icon is shown as fallback. +- **Referrer display names.** Referrers now show friendly names (e.g. “Google”, “Kagi”) using a heuristic from the hostname plus a small override map for famous brands (ChatGPT, LinkedIn, X, etc.). New sites get a sensible name without being added to a list. +- **Top Referrers merged by name.** Rows that map to the same display name (e.g. `chatgpt.com` and `https://chatgpt.com/...`) are merged into one row with combined pageviews, so the same source no longer appears twice. ## [0.2.0-alpha] - 2026-02-11 diff --git a/components/dashboard/ExportModal.tsx b/components/dashboard/ExportModal.tsx index 86ccd64..90f29b3 100644 --- a/components/dashboard/ExportModal.tsx +++ b/components/dashboard/ExportModal.tsx @@ -7,7 +7,7 @@ import jsPDF from 'jspdf' import autoTable from 'jspdf-autotable' import type { DailyStat } from './Chart' import { formatNumber, formatDuration } from '@/lib/utils/format' -import { getReferrerDisplayName } from '@/lib/utils/icons' +import { getReferrerDisplayName, mergeReferrersByDisplayName } from '@/lib/utils/icons' import type { TopPage, TopReferrer } from '@/lib/api/stats' interface ExportModalProps { @@ -279,7 +279,8 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to doc.text('Top Referrers', 14, finalY) finalY += 5 - const referrersData = topReferrers.slice(0, 10).map(r => [getReferrerDisplayName(r.referrer), formatNumber(r.pageviews)]) + const mergedReferrers = mergeReferrersByDisplayName(topReferrers) + const referrersData = mergedReferrers.slice(0, 10).map(r => [getReferrerDisplayName(r.referrer), formatNumber(r.pageviews)]) autoTable(doc, { startY: finalY, diff --git a/components/dashboard/TopReferrers.tsx b/components/dashboard/TopReferrers.tsx index 05add44..5906876 100644 --- a/components/dashboard/TopReferrers.tsx +++ b/components/dashboard/TopReferrers.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import { formatNumber } from '@/lib/utils/format' -import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon } from '@/lib/utils/icons' +import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeReferrersByDisplayName } from '@/lib/utils/icons' import { Modal, GlobeIcon } from '@ciphera-net/ui' import { getTopReferrers, TopReferrer } from '@/lib/api/stats' @@ -26,10 +26,12 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI ref => ref.referrer && ref.referrer !== 'Unknown' && ref.referrer !== '' ) - const hasData = filteredReferrers.length > 0 - const displayedReferrers = hasData ? filteredReferrers.slice(0, LIMIT) : [] + const mergedReferrers = mergeReferrersByDisplayName(filteredReferrers) + + const hasData = mergedReferrers.length > 0 + const displayedReferrers = hasData ? mergedReferrers.slice(0, LIMIT) : [] const emptySlots = Math.max(0, LIMIT - displayedReferrers.length) - const showViewAll = hasData && filteredReferrers.length > LIMIT + const showViewAll = hasData && mergedReferrers.length > LIMIT function renderReferrerIcon(referrer: string) { const faviconUrl = getReferrerFavicon(referrer) @@ -137,7 +139,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI

Loading...

) : ( - (fullData.length > 0 ? fullData : filteredReferrers).map((ref, index) => ( + mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).map((ref, index) => (
{renderReferrerIcon(ref.referrer)} diff --git a/lib/utils/icons.tsx b/lib/utils/icons.tsx index 0293253..3b71b46 100644 --- a/lib/utils/icons.tsx +++ b/lib/utils/icons.tsx @@ -80,37 +80,31 @@ export function getReferrerIcon(referrerName: string) { const REFERRER_NO_FAVICON = ['direct', 'unknown', ''] +/** Common subdomains to skip when deriving the main label (e.g. l.instagram.com → instagram). */ +const REFERRER_SUBDOMAIN_SKIP = new Set([ + 'www', 'm', 'l', 'app', 'mobile', 'search', 'mail', 'drive', 'maps', 'docs', + 'sub', 'api', 'static', 'cdn', 'blog', 'shop', 'support', 'help', 'link', +]) + /** - * Map of referrer hostname (lowercase) to display name for the Top Referrers list. - * Unknown hostnames fall back to the original referrer string. + * Override map for display names when the heuristic would be wrong (casing or brand alias). + * Keys: lowercase label or hostname. Values: exact display name. */ -const REFERRER_DISPLAY_NAMES: Record = { - 'google.com': 'Google', - 'www.google.com': 'Google', - 'google.co.uk': 'Google', - 'google.de': 'Google', - 'facebook.com': 'Facebook', - 'www.facebook.com': 'Facebook', - 'm.facebook.com': 'Facebook', - 'instagram.com': 'Instagram', - 'www.instagram.com': 'Instagram', - 'l.instagram.com': 'Instagram', - 'twitter.com': 'X', - 'www.twitter.com': 'X', +const REFERRER_DISPLAY_OVERRIDES: Record = { + chatgpt: 'ChatGPT', + linkedin: 'LinkedIn', + youtube: 'YouTube', + reddit: 'Reddit', + github: 'GitHub', + duckduckgo: 'DuckDuckGo', + whatsapp: 'WhatsApp', + telegram: 'Telegram', + pinterest: 'Pinterest', + snapchat: 'Snapchat', + tumblr: 'Tumblr', + quora: 'Quora', 't.co': 'X', 'x.com': 'X', - 'linkedin.com': 'LinkedIn', - 'www.linkedin.com': 'LinkedIn', - 'github.com': 'GitHub', - 'www.github.com': 'GitHub', - 'youtube.com': 'YouTube', - 'www.youtube.com': 'YouTube', - 'reddit.com': 'Reddit', - 'www.reddit.com': 'Reddit', - 'chatgpt.com': 'ChatGPT', - 'www.chatgpt.com': 'ChatGPT', - 'ciphera.net': 'Ciphera', - 'www.ciphera.net': 'Ciphera', } /** @@ -128,9 +122,26 @@ function getReferrerHostname(referrer: string): string | null { } } +/** + * Derives the main label from a hostname (e.g. "l.instagram.com" → "instagram", "google.com" → "google"). + */ +function getReferrerLabel(hostname: string): string { + const withoutWww = hostname.startsWith('www.') ? hostname.slice(4) : hostname + const parts = withoutWww.split('.') + if (parts.length >= 2 && REFERRER_SUBDOMAIN_SKIP.has(parts[0])) { + return parts[1] + } + return parts[0] ?? withoutWww +} + +function capitalizeLabel(label: string): string { + if (!label) return label + return label.charAt(0).toUpperCase() + label.slice(1).toLowerCase() +} + /** * Returns a friendly display name for the referrer (e.g. "Google" instead of "google.com"). - * Falls back to the original referrer string when no mapping exists. + * Uses a heuristic (hostname → main label → capitalize) plus a small override map for famous brands. */ export function getReferrerDisplayName(referrer: string): string { if (!referrer || typeof referrer !== 'string') return referrer || '' @@ -138,9 +149,38 @@ export function getReferrerDisplayName(referrer: string): string { if (trimmed === '') return '' const hostname = getReferrerHostname(trimmed) if (!hostname) return trimmed - const displayName = REFERRER_DISPLAY_NAMES[hostname] - if (displayName) return displayName - return trimmed + const overrideByHostname = REFERRER_DISPLAY_OVERRIDES[hostname] + if (overrideByHostname) return overrideByHostname + const label = getReferrerLabel(hostname) + const overrideByLabel = REFERRER_DISPLAY_OVERRIDES[label] + if (overrideByLabel) return overrideByLabel + return capitalizeLabel(label) +} + +/** + * Merges referrer rows that share the same display name (e.g. chatgpt.com and https://chatgpt.com/...), + * summing pageviews and keeping one referrer per group for icon/tooltip. Sorted by pageviews desc. + */ +export function mergeReferrersByDisplayName( + items: Array<{ referrer: string; pageviews: number }> +): Array<{ referrer: string; pageviews: number }> { + const byDisplayName = new Map() + for (const ref of items) { + const name = getReferrerDisplayName(ref.referrer) + const existing = byDisplayName.get(name) + if (!existing) { + byDisplayName.set(name, { referrer: ref.referrer, pageviews: ref.pageviews, maxSingle: ref.pageviews }) + } else { + existing.pageviews += ref.pageviews + if (ref.pageviews > existing.maxSingle) { + existing.maxSingle = ref.pageviews + existing.referrer = ref.referrer + } + } + } + return Array.from(byDisplayName.values()) + .map(({ referrer, pageviews }) => ({ referrer, pageviews })) + .sort((a, b) => b.pageviews - a.pageviews) } /**