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)
}
/**